metame-cli 1.5.21 → 1.5.22

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.
@@ -1,7 +1,8 @@
1
1
  'use strict';
2
2
 
3
3
  const { resolveEngineModel } = require('./daemon-engine-runtime');
4
- const { rawChatId: extractOriginalChatId } = require('./core/thread-chat-id');
4
+ const { createAgentIntentHandler } = require('./daemon-agent-intent');
5
+ const { rawChatId: extractOriginalChatId, isThreadChatId } = require('./core/thread-chat-id');
5
6
 
6
7
  function createCommandRouter(deps) {
7
8
  const {
@@ -135,82 +136,14 @@ function createCommandRouter(deps) {
135
136
  return false;
136
137
  }
137
138
 
138
- function extractQuotedContent(input) {
139
- const m = String(input || '').match(/[“"'「](.+?)[”"'」]/);
140
- return m ? m[1].trim() : '';
141
- }
142
-
143
- function extractPathFromText(input) {
144
- const m = String(input || '').match(/(?:~\/|\/|\.\/|\.\.\/)[^\s,。;;!!??"“”'‘’`]+/);
145
- if (!m) return '';
146
- return m[0].replace(/[,。;;!!??]+$/, '');
147
- }
148
-
149
- function extractAgentName(input) {
150
- const text = String(input || '').trim();
151
- const byNameField = text.match(/(?:名字|名称|叫做?|名为|named?)\s*(?:为)?\s*[“"'「]?([^\s,。;;!!??"“”'‘’`]+)[”"'」]?/i);
152
- if (byNameField) return byNameField[1].trim();
153
- const byBind = text.match(/(?:bind|绑定)\s*(?:到|为|成)?\s*[“"'「]?([a-zA-Z0-9_\-\u4e00-\u9fa5]+)[”"'」]?/i);
154
- if (byBind) return byBind[1].trim();
155
- return '';
156
- }
157
-
158
- function deriveAgentName(input, workspaceDir) {
159
- const explicit = extractAgentName(input);
160
- if (explicit) return explicit;
161
- if (workspaceDir) {
162
- const basename = workspaceDir.split(/[/\\]/).filter(Boolean).pop();
163
- if (basename) return basename;
164
- }
165
- return 'workspace-agent';
166
- }
167
-
168
- function deriveRoleDelta(input) {
169
- const text = String(input || '').trim();
170
- const quoted = extractQuotedContent(text);
171
- if (quoted) return quoted;
172
- const byVerb = text.match(/(?:改成|改为|变成|设为|更新为)\s*[::]?\s*(.+)$/);
173
- if (byVerb) return byVerb[1].trim();
174
- return text;
175
- }
176
-
177
- function deriveCreateRoleDelta(input) {
178
- const text = String(input || '').trim();
179
- const quoted = extractQuotedContent(text);
180
- if (quoted) return quoted;
181
- const byRoleField = text.match(/(?:角色|职责|人设)\s*(?:是|为|:|:)?\s*(.+)$/i);
182
- if (byRoleField) return byRoleField[1].trim();
183
- return '';
184
- }
185
-
186
- function inferAgentEngineFromText(input) {
187
- const text = String(input || '').trim().toLowerCase();
188
- if (!text) return null;
189
- if (/\bcodex\b/.test(text) || /柯德|科德/.test(text)) return 'codex';
190
- return null;
191
- }
192
-
193
- function isLikelyDirectAgentAction(input) {
194
- const text = String(input || '').trim();
195
- return /^(?:请|帮我|麻烦|给我|给这个群|给当前群|在这个群|把这个群|把当前群|将这个群|这个群|当前群|本群|群里|我想|我要|我需要|创建|新建|新增|搞一个|加一个|create|bind|绑定|列出|查看|显示|有哪些|解绑|取消绑定|断开绑定|修改|调整)/i.test(text);
196
- }
197
-
198
- function looksLikeAgentIssueReport(input) {
199
- const text = String(input || '').trim();
200
- const hasIssueWords = /(用户反馈|反馈|报错|bug|问题|故障|异常|修复|改一下|修一下|任务|工单|代码)/i.test(text);
201
- const hasAgentWords = /(agent|智能体|session|会话|目录|工作区|绑定|切换)/i.test(text);
202
- return hasIssueWords && hasAgentWords;
203
- }
204
-
205
- function projectNameFromResult(data, fallbackName) {
206
- if (data && data.project && data.project.name) return data.project.name;
207
- if (data && data.projectKey) return data.projectKey;
208
- return fallbackName || 'workspace-agent';
209
- }
210
-
211
139
  function projectKeyFromVirtualChatId(chatId) {
212
140
  const v = String(chatId || '');
213
- if (v.startsWith('_agent_')) return v.slice(7) || null;
141
+ if (v.startsWith('_agent_')) {
142
+ const rest = v.slice(7);
143
+ const scopeIdx = rest.indexOf('::');
144
+ const key = scopeIdx >= 0 ? rest.slice(0, scopeIdx) : rest;
145
+ return key || null;
146
+ }
214
147
  if (v.startsWith('_scope_')) {
215
148
  const idx = v.lastIndexOf('__');
216
149
  if (idx > 7 && idx + 2 < v.length) return v.slice(idx + 2);
@@ -222,6 +155,9 @@ function createCommandRouter(deps) {
222
155
  const rawChatId = String(chatId || '');
223
156
  const inferredKey = projectKey || projectKeyFromVirtualChatId(rawChatId);
224
157
  if (rawChatId.startsWith('_agent_') || rawChatId.startsWith('_scope_')) return rawChatId;
158
+ // Feishu topics must keep per-thread isolation even when the thread is
159
+ // temporarily routed to a named agent/project via nickname or chat_agent_map.
160
+ if (isThreadChatId(rawChatId)) return rawChatId;
225
161
  return inferredKey ? `_bound_${inferredKey}` : rawChatId;
226
162
  }
227
163
 
@@ -407,226 +343,20 @@ function createCommandRouter(deps) {
407
343
  });
408
344
  }
409
345
 
410
- function _detectCloneIntent(text) {
411
- if (!text || text.startsWith('/') || text.length < 3) return false;
412
- const cloneKeywords = ['分身', '再造', '克隆', '副本', '另一个自己', '另一个我'];
413
- const hasCloneKeyword = cloneKeywords.some(k => text.includes(k));
414
- if (hasCloneKeyword) {
415
- const excludePatterns = [/已经/, /存在/, /有了/, /好了/, /完成/, /搞定/, /配置好/, /怎么建/, /如何建/, /方法/, /步骤/];
416
- if (excludePatterns.some(p => p.test(text))) return false;
417
- return true;
418
- }
419
- const actionKeywords = ['新建', '创建', '造', '做一个', '加一个', '增加', '添加'];
420
- const hasAction = actionKeywords.some(k => text.includes(k));
421
- if (hasAction && /分身|数字/.test(text)) return true;
422
- if (/让.*做分身|叫.*做分身|甲.*做分身/.test(text)) return true;
423
- return false;
424
- }
425
-
426
- function _detectNewAgentIntent(text) {
427
- if (!text || text.startsWith('/') || text.length < 3) return false;
428
- if (_detectCloneIntent(text)) return false;
429
- if (_detectTeamIntent(text)) return false;
430
- const agentKeywords = ['agent', '助手', '机器人', '小助手'];
431
- const hasAgentKeyword = agentKeywords.some(k => text.toLowerCase().includes(k.toLowerCase()));
432
- const actionKeywords = ['新建', '创建', '造', '做一个', '加一个', '增加', '添加', '开一个'];
433
- const hasAction = actionKeywords.some(k => text.includes(k));
434
- if (hasAgentKeyword && hasAction) {
435
- const excludePatterns = [/已经/, /存在/, /有了/, /好了/, /完成/, /搞定/, /配置好/, /怎么建/, /如何建/, /方法/, /步骤/, /是什么/, /哪个/];
436
- if (excludePatterns.some(p => p.test(text))) return false;
437
- return true;
438
- }
439
- if (/^(给我|帮我|我要|我想|给我加|帮我加)/.test(text) && hasAgentKeyword) return true;
440
- return false;
441
- }
442
-
443
- function _detectTeamIntent(text) {
444
- if (!text || text.startsWith('/') || text.length < 4) return false;
445
- // Exclude: only mentioning team, no creation intent
446
- if (/走team|用team|通过team|team里|team中|团队里|团队中|走团队|用团队|在team|在团队|team.*已经|团队.*已经|team.*讨论|团队.*讨论/.test(text)) return false;
447
- // Positive match: team + action word
448
- if ((text.includes('团队') || text.includes('工作组'))) {
449
- if (/(新建|创建|造一个|加一个|组建|设置|建|搞)/.test(text)) {
450
- if (/怎么|如何|方法|步骤/.test(text)) return false;
451
- return true;
452
- }
453
- }
454
- // Pattern: "建个团队" / "搞个团队"
455
- if (/^(新建|创建|建|搞).*团队/.test(text)) return true;
456
- return false;
457
- }
458
-
459
- async function tryHandleAgentIntent(bot, chatId, text, config) {
460
- if (!agentTools || !text || text.startsWith('/')) return false;
461
- const key = String(chatId);
462
- if (hasFreshPendingFlow(key) || hasFreshPendingFlow(key + ':edit')) return false;
463
- const input = text.trim();
464
- if (!input) return false;
465
-
466
- // Clone intent — route to /agent new clone wizard
467
- if (_detectCloneIntent(input)) {
468
- log('INFO', `[CloneIntent] "${input.slice(0, 80)}" → /agent new clone`);
469
- await handleAgentCommand({ bot, chatId, text: '/agent new clone', config });
470
- return true;
471
- }
472
-
473
- // New agent intent — route to /agent new wizard
474
- if (_detectNewAgentIntent(input)) {
475
- log('INFO', `[NewAgentIntent] "${input.slice(0, 80)}" → /agent new`);
476
- await handleAgentCommand({ bot, chatId, text: '/agent new', config });
477
- return true;
478
- }
479
-
480
- // Team creation intent — route to /agent new team wizard
481
- if (_detectTeamIntent(input)) {
482
- log('INFO', `[TeamIntent] "${input.slice(0, 80)}" → /agent new team`);
483
- await handleAgentCommand({ bot, chatId, text: '/agent new team', config });
484
- return true;
485
- }
486
-
487
- const directAction = isLikelyDirectAgentAction(input);
488
- const issueReport = looksLikeAgentIssueReport(input);
489
- if (issueReport && !directAction) return false;
490
- const workspaceDir = extractPathFromText(input);
491
- const hasWorkspacePath = !!workspaceDir;
492
-
493
- // Exclude third-party product context — "智能体" about other companies is NOT about our agents
494
- // Requires BOTH a company name AND agent-related keyword to trigger, avoiding false positives on generic verbs
495
- const _hasThirdPartyName = /(阿里|百度|腾讯|字节|谷歌|google|openai|微软|microsoft|deepseek|豆包|通义|文心|kimi)/i.test(input);
496
- const _hasAgentWord = /(智能体|agent|助手|机器人)/i.test(input);
497
- const _isAboutOurAgents = /(我的|我们的|当前|这个群|这里的|metame)/i.test(input);
498
- if (_hasThirdPartyName && _hasAgentWord && !_isAboutOurAgents) return false;
499
-
500
- const hasAgentContext = /(agent|智能体|工作区|人设|绑定|当前群|这个群|chat|workspace)/i.test(input);
501
- const wantsList = /(列出|查看|显示|有哪些|list|show)/i.test(input) && /(agent|智能体|工作区|绑定)/i.test(input);
502
- const wantsUnbind = /(解绑|取消绑定|断开绑定|unbind|unassign)/i.test(input) && hasAgentContext;
503
- const wantsEditRole =
504
- ((/(角色|职责|人设)/i.test(input) && /(改|修改|调整|更新|变成|改成|改为)/i.test(input)) ||
505
- /(把这个agent|把当前agent|当前群.*角色|当前群.*职责)/i.test(input));
506
- const wantsCreate =
507
- (/(创建|新建|新增|搞一个|加一个|create)/i.test(input) && /(agent|智能体|人设|工作区)/i.test(input) && (directAction || hasWorkspacePath));
508
- const wantsBind =
509
- !wantsCreate &&
510
- (/(绑定|bind)/i.test(input) && hasAgentContext && (directAction || hasWorkspacePath));
511
-
512
- if (!wantsList && !wantsUnbind && !wantsEditRole && !wantsCreate && !wantsBind) {
513
- return false;
514
- }
515
-
516
- if (wantsList) {
517
- const res = await agentTools.listAllAgents(chatId);
518
- if (!res.ok) {
519
- await bot.sendMessage(chatId, `❌ 查询 Agent 失败: ${res.error}`);
520
- return true;
521
- }
522
- const agents = res.data.agents || [];
523
- if (agents.length === 0) {
524
- await bot.sendMessage(chatId, '暂无已配置的 Agent。你可以直接说“给这个群创建一个 Agent,目录是 ~/xxx”。');
525
- return true;
526
- }
527
- const lines = ['📋 当前 Agent 列表', ''];
528
- for (const a of agents) {
529
- const marker = a.key === res.data.boundKey ? ' ◀ 当前' : '';
530
- lines.push(`${a.icon || '🤖'} ${a.name}${marker}`);
531
- lines.push(`目录: ${a.cwd}`);
532
- lines.push(`Key: ${a.key}`);
533
- lines.push('');
534
- }
535
- await bot.sendMessage(chatId, lines.join('\n').trimEnd());
536
- return true;
537
- }
538
-
539
- if (wantsUnbind) {
540
- const res = await agentTools.unbindCurrentAgent(chatId);
541
- if (!res.ok) {
542
- await bot.sendMessage(chatId, `❌ 解绑失败: ${res.error}`);
543
- return true;
544
- }
545
- if (res.data.unbound) {
546
- await bot.sendMessage(chatId, `✅ 已解绑当前群(原 Agent: ${res.data.previousProjectKey})`);
547
- } else {
548
- await bot.sendMessage(chatId, '当前群没有绑定 Agent,无需解绑。');
549
- }
550
- return true;
551
- }
552
-
553
- if (wantsEditRole) {
554
- const freshCfg = loadConfig();
555
- const bound = getBoundProjectForChat(chatId, freshCfg);
556
- if (!bound.project || !bound.project.cwd) {
557
- await bot.sendMessage(chatId, '❌ 当前群未绑定 Agent。先说“给这个群绑定一个 Agent,目录是 ~/xxx”。');
558
- return true;
559
- }
560
- // Lazy migration: ensure soul layer exists for agents created before this feature
561
- if (agentTools && typeof agentTools.repairAgentSoul === 'function') {
562
- await agentTools.repairAgentSoul(bound.project.cwd).catch(() => {});
563
- }
564
- const roleDelta = deriveRoleDelta(input);
565
- const res = await agentTools.editAgentRoleDefinition(bound.project.cwd, roleDelta);
566
- if (!res.ok) {
567
- await bot.sendMessage(chatId, `❌ 更新角色失败: ${res.error}`);
568
- return true;
569
- }
570
- await bot.sendMessage(chatId, res.data.created ? '✅ 已创建 CLAUDE.md 并写入角色定义' : '✅ 角色定义已更新到 CLAUDE.md');
571
- return true;
572
- }
573
-
574
- if (wantsCreate) {
575
- if (!workspaceDir) {
576
- await bot.sendMessage(chatId, [
577
- '我可以帮你创建 Agent,还差一个工作目录。',
578
- '例如:`给这个群创建一个 Agent,目录是 ~/projects/foo`',
579
- '也可以直接回我一个路径(`~/`、`/`、`./`、`../` 开头都行)。',
580
- ].join('\n'));
581
- return true;
582
- }
583
- const agentName = deriveAgentName(input, workspaceDir);
584
- const roleDelta = deriveCreateRoleDelta(input);
585
- const inferredEngine = inferAgentEngineFromText(input);
586
- // Always skip binding creating chat — new group activates via /activate
587
- const res = await agentTools.createNewWorkspaceAgent(agentName, workspaceDir, roleDelta, chatId, {
588
- skipChatBinding: true,
589
- engine: inferredEngine,
590
- });
591
- if (!res.ok) {
592
- await bot.sendMessage(chatId, `❌ 创建 Agent 失败: ${res.error}`);
593
- return true;
594
- }
595
- const data = res.data || {};
596
- const projName = projectNameFromResult(data, agentName);
597
- const engineTip = data.project && data.project.engine ? `\n引擎: ${data.project.engine}` : '';
598
- if (data.projectKey && pendingActivations) {
599
- pendingActivations.set(data.projectKey, {
600
- agentKey: data.projectKey, agentName: projName, cwd: data.cwd,
601
- createdByChatId: String(chatId), createdAt: Date.now(),
602
- });
603
- }
604
- await bot.sendMessage(chatId,
605
- `✅ Agent「${projName}」已创建\n目录: ${data.cwd || '(未知)'}${engineTip}\n\n` +
606
- `**下一步**: 在新群里发送 \`/activate\` 完成绑定(30分钟内有效)`
607
- );
608
- return true;
609
- }
610
-
611
- if (wantsBind) {
612
- const agentName = deriveAgentName(input, workspaceDir);
613
- const inferredEngine = inferAgentEngineFromText(input);
614
- const res = await agentTools.bindAgentToChat(chatId, agentName, workspaceDir || null, { engine: inferredEngine });
615
- if (!res.ok) {
616
- await bot.sendMessage(chatId, `❌ 绑定失败: ${res.error}`);
617
- return true;
618
- }
619
- const data = res.data || {};
620
- const projName = projectNameFromResult(data, agentName);
621
- if (data.cwd) attachOrCreateSession(chatId, normalizeCwd(data.cwd), projName, (data.project && data.project.engine) || getDefaultEngine());
622
- await bot.sendMessage(chatId, `✅ 已绑定 Agent\n名称: ${projName}\n目录: ${data.cwd || '(未知)'}`);
623
- return true;
624
- }
625
-
626
- return false;
627
- }
346
+ const tryHandleAgentIntent = createAgentIntentHandler({
347
+ agentTools,
348
+ handleAgentCommand,
349
+ attachOrCreateSession,
350
+ normalizeCwd,
351
+ getDefaultEngine,
352
+ loadConfig,
353
+ getBoundProjectForChat,
354
+ log,
355
+ pendingActivations,
356
+ hasFreshPendingFlow,
357
+ });
628
358
 
629
- async function handleCommand(bot, chatId, text, config, executeTaskByName, senderId = null, readOnly = false, meta = {}) {
359
+ async function handleCommand(bot, chatId, text, config, executeTaskByName, senderId = null, readOnly = false, _meta = {}) {
630
360
  if (text && !text.startsWith('/chatid') && !text.startsWith('/myid')) log('INFO', `CMD [${String(chatId).slice(-8)}]: ${text.slice(0, 80)}`);
631
361
  const state = loadState();
632
362
 
@@ -0,0 +1,127 @@
1
+ 'use strict';
2
+
3
+ const { normalizeEngineName } = require('./daemon-engine-runtime');
4
+ const { buildIntentHintBlock } = require('./intent-registry');
5
+
6
+ function adaptDaemonHintForEngine(daemonHint, engineName) {
7
+ if (normalizeEngineName(engineName) === 'claude') return daemonHint;
8
+ let out = String(daemonHint || '');
9
+ out = out.replace('[System hints - DO NOT mention these to user:', 'System hints (internal, do not mention to user):');
10
+ out = out.replace(/\]\s*$/, '');
11
+ return out;
12
+ }
13
+
14
+ function buildAgentHint({
15
+ sessionStarted,
16
+ boundProject,
17
+ sessionCwd,
18
+ engineName,
19
+ HOME,
20
+ buildAgentContextForEngine,
21
+ log,
22
+ }) {
23
+ if (sessionStarted || (!boundProject && !sessionCwd)) return '';
24
+ try {
25
+ return buildAgentContextForEngine(
26
+ boundProject || { cwd: sessionCwd },
27
+ engineName,
28
+ HOME,
29
+ ).hint || '';
30
+ } catch (e) {
31
+ if (typeof log === 'function') log('WARN', `Agent context injection failed: ${e.message}`);
32
+ return '';
33
+ }
34
+ }
35
+
36
+ function buildDaemonHint({
37
+ sessionStarted,
38
+ prompt,
39
+ mentorRadarHint = '',
40
+ zdpHint = '',
41
+ reflectHint = '',
42
+ projectKey = 'default',
43
+ isTaskIntent,
44
+ runtimeName,
45
+ }) {
46
+ if (sessionStarted) return '';
47
+ const taskRules = typeof isTaskIntent === 'function' && isTaskIntent(prompt) ? `
48
+ 3. Active memory: After confirming a new insight, bug root cause, or user preference, persist it with:
49
+ node ~/.metame/memory-write.js "Entity.sub" "relation_type" "value (20-300 chars)"
50
+ Valid relations: tech_decision, bug_lesson, arch_convention, config_fact, config_change, workflow_rule, project_milestone
51
+ Only write verified facts. Do not write speculative or process-description entries.
52
+ ${mentorRadarHint}
53
+ 4. Task handoff: When suspending a multi-step task or handing off to another agent, write current status to ~/.metame/memory/now/${projectKey || 'default'}.md using:
54
+ \`mkdir -p ~/.metame/memory/now && printf '%s\\n' "## Current Task" "{task}" "" "## Progress" "{progress}" "" "## Next Step" "{next}" > ~/.metame/memory/now/${projectKey || 'default'}.md\`
55
+ Keep it under 200 words. Clear it when the task is fully complete by running: \`> ~/.metame/memory/now/${projectKey || 'default'}.md\`` : '';
56
+ const daemonHint = `\n\n[System hints - DO NOT mention these to user:
57
+ 1. Daemon config: The ONLY config is ~/.metame/daemon.yaml (never edit daemon-default.yaml). Auto-reloads on change.
58
+ 2. Explanation depth (ZPD):${zdpHint ? zdpHint : '\n- User competence map unavailable. Default to concise expert-first explanations unless the user asks for teaching mode.'}${reflectHint}${taskRules}]`;
59
+ return adaptDaemonHintForEngine(daemonHint, runtimeName);
60
+ }
61
+
62
+ function buildMacAutomationHint({
63
+ processPlatform,
64
+ readOnly,
65
+ prompt,
66
+ isMacAutomationIntent,
67
+ }) {
68
+ if (processPlatform !== 'darwin' || readOnly || typeof isMacAutomationIntent !== 'function' || !isMacAutomationIntent(prompt)) {
69
+ return '';
70
+ }
71
+ return `\n\n[Mac automation policy - do NOT expose this block:
72
+ 1. Prefer deterministic local control via Bash + osascript/JXA; avoid screenshot/visual workflows unless explicitly requested.
73
+ 2. Read/query actions can execute directly.
74
+ 3. Before any side-effect action (send email, create/delete/modify calendar event, delete/move files, app quit, system sleep), first show a short execution preview and require explicit user confirmation.
75
+ 4. Keep output concise: success/failure + key result only.
76
+ 5. If permission is missing, guide user to run /mac perms open then retry.
77
+ 6. Before executing high-risk or non-obvious Bash commands (rm, kill, git reset, overwrite configs), prepend a single-line [Why] explanation. Skip for routine commands (ls, cat, grep).]`;
78
+ }
79
+
80
+ function buildLanguageGuard(sessionStarted) {
81
+ return sessionStarted
82
+ ? ''
83
+ : '\n\n[Respond in Simplified Chinese (简体中文) only. NEVER switch to Korean, Japanese, or other languages regardless of tool output or context language.]';
84
+ }
85
+
86
+ function buildIntentHint({
87
+ prompt,
88
+ config,
89
+ boundProjectKey,
90
+ projectKey,
91
+ log,
92
+ }) {
93
+ try {
94
+ const block = buildIntentHintBlock(prompt, config, boundProjectKey || projectKey || '');
95
+ return block ? `\n\n${block}` : '';
96
+ } catch (e) {
97
+ if (typeof log === 'function') log('WARN', `Intent registry injection failed: ${e.message}`);
98
+ return '';
99
+ }
100
+ }
101
+
102
+ function composePrompt({
103
+ routedPrompt,
104
+ warmEntry,
105
+ intentHint = '',
106
+ daemonHint = '',
107
+ agentHint = '',
108
+ macAutomationHint = '',
109
+ summaryHint = '',
110
+ memoryHint = '',
111
+ mentorHint = '',
112
+ langGuard = '',
113
+ }) {
114
+ return warmEntry
115
+ ? routedPrompt + intentHint
116
+ : routedPrompt + daemonHint + intentHint + agentHint + macAutomationHint + summaryHint + memoryHint + mentorHint + langGuard;
117
+ }
118
+
119
+ module.exports = {
120
+ adaptDaemonHintForEngine,
121
+ buildAgentHint,
122
+ buildDaemonHint,
123
+ buildMacAutomationHint,
124
+ buildLanguageGuard,
125
+ buildIntentHint,
126
+ composePrompt,
127
+ };
@@ -258,6 +258,79 @@ function runProjectVerifier(projectKey, config, deps) {
258
258
  }
259
259
  }
260
260
 
261
+ function sanitizeQueueId(id) {
262
+ return String(id || '').replace(/[^a-zA-Z0-9_-]/g, '');
263
+ }
264
+
265
+ function createMissionStartPrompt(title) {
266
+ return `New mission: "${title}"\n\nStart this mission. Read your CLAUDE.md for instructions, then decide on the first step using NEXT_DISPATCH.`;
267
+ }
268
+
269
+ function loadMissionQueueState(projectKey, projectCwd, deps) {
270
+ const manifest = loadProjectManifest(projectCwd);
271
+ const scripts = resolveProjectScripts(projectCwd, manifest);
272
+ if (!fs.existsSync(scripts.missionQueue)) {
273
+ return { manifest, scripts, listResult: null, activeMission: null, nextMission: null };
274
+ }
275
+
276
+ const relQueue = path.relative(projectCwd, scripts.missionQueue);
277
+ const queueEnv = { ...process.env, MISSION_CWD: projectCwd, TOPICS_CWD: projectCwd };
278
+ let listResult = null;
279
+ try {
280
+ const listOut = execSync(`node "${relQueue}" list`, {
281
+ cwd: projectCwd, encoding: 'utf8', timeout: 10000, env: queueEnv,
282
+ }).trim();
283
+ listResult = JSON.parse(listOut);
284
+ } catch (e) {
285
+ deps.log('WARN', `Reactive: mission queue list failed for ${projectKey}: ${e.message}`);
286
+ }
287
+
288
+ const topics = Array.isArray(listResult && listResult.topics) ? listResult.topics : [];
289
+ return {
290
+ manifest,
291
+ scripts,
292
+ relQueue,
293
+ queueEnv,
294
+ listResult,
295
+ activeMission: topics.find(t => t.status === 'active') || null,
296
+ nextMission: topics.filter(t => t.status === 'pending').sort((a, b) => (a.priority || 999) - (b.priority || 999))[0] || null,
297
+ };
298
+ }
299
+
300
+ function activateQueuedMission(projectKey, projectCwd, deps) {
301
+ const queueState = loadMissionQueueState(projectKey, projectCwd, deps);
302
+ const { scripts, relQueue, queueEnv } = queueState;
303
+ if (!scripts || !fs.existsSync(scripts.missionQueue)) {
304
+ return { mission: null, prompt: null, missionId: null };
305
+ }
306
+
307
+ if (queueState.activeMission) {
308
+ return {
309
+ mission: queueState.activeMission.title,
310
+ missionId: queueState.activeMission.id || '',
311
+ prompt: createMissionStartPrompt(queueState.activeMission.title),
312
+ reusedActive: true,
313
+ };
314
+ }
315
+
316
+ if (!queueState.nextMission) return { mission: null, prompt: null, missionId: null };
317
+
318
+ try {
319
+ execSync(`node "${relQueue}" activate ${sanitizeQueueId(queueState.nextMission.id)}`, {
320
+ cwd: projectCwd, encoding: 'utf8', timeout: 10000, env: queueEnv,
321
+ });
322
+ } catch (e) {
323
+ deps.log('WARN', `Reactive: mission activate failed for ${projectKey}: ${e.message}`);
324
+ }
325
+
326
+ return {
327
+ mission: queueState.nextMission.title,
328
+ missionId: queueState.nextMission.id || '',
329
+ prompt: createMissionStartPrompt(queueState.nextMission.title),
330
+ reusedActive: false,
331
+ };
332
+ }
333
+
261
334
  /**
262
335
  * Run project-level completion hooks (archive + topic pool).
263
336
  * Platform only calls scripts if they exist — no business logic here.
@@ -298,8 +371,6 @@ function runCompletionHooks(projectKey, projectCwd, deps) {
298
371
  if (result.archived && fs.existsSync(scripts.missionQueue)) {
299
372
  const relQueue = path.relative(projectCwd, scripts.missionQueue);
300
373
  const queueEnv = { ...process.env, MISSION_CWD: projectCwd, TOPICS_CWD: projectCwd };
301
- // Sanitize topic IDs to prevent shell injection (only allow alphanumeric, dash, underscore)
302
- const sanitizeId = (id) => String(id || '').replace(/[^a-zA-Z0-9_-]/g, '');
303
374
  // 2a. Complete current active mission
304
375
  try {
305
376
  const listOut = execSync(`node "${relQueue}" list`, {
@@ -309,7 +380,7 @@ function runCompletionHooks(projectKey, projectCwd, deps) {
309
380
  if (listResult.success && Array.isArray(listResult.topics)) {
310
381
  const activeTopic = listResult.topics.find(t => t.status === 'active');
311
382
  if (activeTopic) {
312
- execSync(`node "${relQueue}" complete ${sanitizeId(activeTopic.id)}`, {
383
+ execSync(`node "${relQueue}" complete ${sanitizeQueueId(activeTopic.id)}`, {
313
384
  cwd: projectCwd, encoding: 'utf8', timeout: 10000, env: queueEnv,
314
385
  });
315
386
  deps.log('INFO', `Reactive: completed mission ${activeTopic.id}: ${activeTopic.title}`);
@@ -326,7 +397,7 @@ function runCompletionHooks(projectKey, projectCwd, deps) {
326
397
  const nextResult = JSON.parse(nextOut);
327
398
  if (nextResult.success && nextResult.topic) {
328
399
  try {
329
- execSync(`node "${relQueue}" activate ${sanitizeId(nextResult.topic.id)}`, {
400
+ execSync(`node "${relQueue}" activate ${sanitizeQueueId(nextResult.topic.id)}`, {
330
401
  cwd: projectCwd, encoding: 'utf8', timeout: 10000, env: queueEnv,
331
402
  });
332
403
  } catch (e) {
@@ -334,7 +405,7 @@ function runCompletionHooks(projectKey, projectCwd, deps) {
334
405
  }
335
406
  result.nextMission = nextResult.topic.title;
336
407
  result.nextMissionId = nextResult.topic.id || '';
337
- result.nextMissionPrompt = `New mission: "${nextResult.topic.title}"\n\nStart this mission. Read your CLAUDE.md for instructions, then decide on the first step using NEXT_DISPATCH.`;
408
+ result.nextMissionPrompt = createMissionStartPrompt(nextResult.topic.title);
338
409
  deps.log('INFO', `Reactive: next mission for ${projectKey}: ${nextResult.topic.title}`);
339
410
  }
340
411
  } catch (e) {
@@ -347,6 +418,66 @@ function runCompletionHooks(projectKey, projectCwd, deps) {
347
418
  return result;
348
419
  }
349
420
 
421
+ function bootstrapReactiveProject(projectKey, config, deps) {
422
+ if (!isReactiveParent(projectKey, config)) {
423
+ return { started: false, reason: 'not_reactive' };
424
+ }
425
+
426
+ const st = deps.loadState();
427
+ const rs = getReactiveState(st, projectKey);
428
+ if (!deps.checkBudget(config, st)) {
429
+ setReactiveStatus(st, projectKey, 'paused', 'budget_exceeded');
430
+ deps.saveState(st);
431
+ appendEvent(projectKey, { type: 'BUDGET_LIMIT', action: 'bootstrap_skip' }, deps.metameDir);
432
+ return { started: false, reason: 'budget_exceeded' };
433
+ }
434
+
435
+ const staleMinutes = config.projects?.[projectKey]?.stale_timeout_minutes || 120;
436
+ const lastUpdateMs = new Date(rs.updated_at || 0).getTime();
437
+ const recentlyUpdated = Number.isFinite(lastUpdateMs) && (Date.now() - lastUpdateMs) < staleMinutes * 60 * 1000;
438
+ if (rs.status === 'running' && isReactiveExecutionActive(projectKey, config, deps)) {
439
+ return { started: false, reason: 'already_running' };
440
+ }
441
+ if (rs.status === 'running' && recentlyUpdated) {
442
+ return { started: false, reason: 'recently_running' };
443
+ }
444
+
445
+ const projectCwd = resolveProjectCwd(projectKey, config);
446
+ if (!projectCwd) return { started: false, reason: 'cwd_missing' };
447
+
448
+ const activation = activateQueuedMission(projectKey, projectCwd, deps);
449
+ if (!activation.mission || !activation.prompt) {
450
+ setReactiveStatus(st, projectKey, 'idle', '');
451
+ rs.depth = 0;
452
+ deps.saveState(st);
453
+ return { started: false, reason: 'no_pending_mission' };
454
+ }
455
+
456
+ setReactiveStatus(st, projectKey, 'running', '');
457
+ rs.depth = 0;
458
+ rs.last_signal = 'MISSION_START';
459
+ deps.saveState(st);
460
+ appendEvent(projectKey, {
461
+ type: 'MISSION_START',
462
+ mission_id: activation.missionId || '',
463
+ mission_title: activation.mission,
464
+ bootstrap: true,
465
+ reused_active: !!activation.reusedActive,
466
+ }, deps.metameDir);
467
+ try { generateStateFile(projectKey, config, deps); } catch { /* non-critical */ }
468
+
469
+ deps.handleDispatchItem({
470
+ target: projectKey,
471
+ prompt: activation.prompt,
472
+ from: '_system',
473
+ _reactive: true,
474
+ _reactive_project: projectKey,
475
+ new_session: true,
476
+ }, config);
477
+
478
+ return { started: true, mission: activation.mission, missionId: activation.missionId || '' };
479
+ }
480
+
350
481
  // ── Event Log (Event Sourcing) ──────────────────────────────────
351
482
 
352
483
  /**
@@ -1195,9 +1326,10 @@ function handleReactiveOutput(targetProject, output, config, deps) {
1195
1326
  }
1196
1327
 
1197
1328
  module.exports = {
1329
+ bootstrapReactiveProject,
1198
1330
  handleReactiveOutput,
1199
1331
  parseReactiveSignals,
1200
1332
  reconcilePerpetualProjects,
1201
1333
  replayEventLog,
1202
- __test: { runProjectVerifier, readPhaseFromState, resolveProjectCwd, appendEvent, projectProgressTsv, generateStateFile, loadProjectManifest, resolveProjectScripts, parseEventLog, buildRunningMemory, scanRelevantArtifacts, buildWorkingMemory, persistMemoryFiles, extractInlineFacts, extractOutputSummary, isReactiveExecutionActive, loadWorkingMemory },
1334
+ __test: { runProjectVerifier, readPhaseFromState, resolveProjectCwd, appendEvent, projectProgressTsv, generateStateFile, loadProjectManifest, resolveProjectScripts, parseEventLog, buildRunningMemory, scanRelevantArtifacts, buildWorkingMemory, persistMemoryFiles, extractInlineFacts, extractOutputSummary, isReactiveExecutionActive, loadWorkingMemory, activateQueuedMission, createMissionStartPrompt, sanitizeQueueId, loadMissionQueueState },
1203
1335
  };