metame-cli 1.5.18 → 1.5.20

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 (41) hide show
  1. package/index.js +157 -80
  2. package/package.json +2 -2
  3. package/scripts/bin/bootstrap-worktree.sh +20 -0
  4. package/scripts/core/audit.js +190 -0
  5. package/scripts/core/handoff.js +780 -0
  6. package/scripts/core/handoff.test.js +1074 -0
  7. package/scripts/core/memory-model.js +183 -0
  8. package/scripts/core/memory-model.test.js +486 -0
  9. package/scripts/core/reactive-paths.js +44 -0
  10. package/scripts/core/reactive-paths.test.js +35 -0
  11. package/scripts/core/reactive-prompt.js +51 -0
  12. package/scripts/core/reactive-prompt.test.js +88 -0
  13. package/scripts/core/reactive-signal.js +40 -0
  14. package/scripts/core/reactive-signal.test.js +88 -0
  15. package/scripts/core/thread-chat-id.js +52 -0
  16. package/scripts/core/thread-chat-id.test.js +113 -0
  17. package/scripts/daemon-bridges.js +79 -35
  18. package/scripts/daemon-claude-engine.js +371 -425
  19. package/scripts/daemon-command-router.js +80 -6
  20. package/scripts/daemon-engine-runtime.js +26 -4
  21. package/scripts/daemon-message-pipeline.js +2 -2
  22. package/scripts/daemon-reactive-lifecycle.js +134 -33
  23. package/scripts/daemon-session-commands.js +133 -43
  24. package/scripts/daemon-session-store.js +300 -82
  25. package/scripts/daemon-team-dispatch.js +16 -16
  26. package/scripts/daemon.js +37 -176
  27. package/scripts/deploy-manifest.js +90 -0
  28. package/scripts/docs/maintenance-manual.md +14 -11
  29. package/scripts/docs/pointer-map.md +13 -4
  30. package/scripts/feishu-adapter.js +31 -27
  31. package/scripts/hooks/intent-engine.js +6 -3
  32. package/scripts/hooks/intent-memory-recall.js +1 -0
  33. package/scripts/hooks/intent-perpetual.js +1 -1
  34. package/scripts/memory-extract.js +5 -97
  35. package/scripts/memory-gc.js +35 -90
  36. package/scripts/memory-migrate-v2.js +304 -0
  37. package/scripts/memory-nightly-reflect.js +40 -41
  38. package/scripts/memory.js +340 -859
  39. package/scripts/migrate-reactive-paths.js +122 -0
  40. package/scripts/signal-capture.js +4 -0
  41. package/scripts/sync-plugin.js +56 -0
@@ -1,6 +1,7 @@
1
1
  'use strict';
2
2
 
3
3
  const { resolveEngineModel } = require('./daemon-engine-runtime');
4
+ const { rawChatId: extractOriginalChatId } = require('./core/thread-chat-id');
4
5
 
5
6
  function createCommandRouter(deps) {
6
7
  const {
@@ -224,6 +225,50 @@ function createCommandRouter(deps) {
224
225
  return inferredKey ? `_bound_${inferredKey}` : rawChatId;
225
226
  }
226
227
 
228
+ function resolveCurrentSessionContext(chatId, config) {
229
+ const chatIdStr = String(chatId || '');
230
+ const chatAgentMap = {
231
+ ...(config && config.telegram ? config.telegram.chat_agent_map : {}),
232
+ ...(config && config.feishu ? config.feishu.chat_agent_map : {}),
233
+ ...(config && config.imessage ? config.imessage.chat_agent_map : {}),
234
+ ...(config && config.siri_bridge ? config.siri_bridge.chat_agent_map : {}),
235
+ };
236
+ const _rawChatId = extractOriginalChatId(chatIdStr);
237
+ const mappedKey = chatAgentMap[chatIdStr] || chatAgentMap[_rawChatId] || projectKeyFromVirtualChatId(chatIdStr);
238
+ const mappedProject = mappedKey && config && config.projects ? config.projects[mappedKey] : null;
239
+ const preferredEngine = String((mappedProject && mappedProject.engine) || getDefaultEngine()).toLowerCase();
240
+ const state = loadState() || {};
241
+ const sessions = state.sessions || {};
242
+ const candidateIds = [
243
+ mappedKey ? buildSessionChatId(chatIdStr, mappedKey) : null,
244
+ buildSessionChatId(chatIdStr),
245
+ chatIdStr,
246
+ ].filter(Boolean);
247
+
248
+ for (const candidateId of candidateIds) {
249
+ const record = sessions[candidateId];
250
+ if (!record) continue;
251
+ const candidateSlots = [];
252
+ if (record.engines && typeof record.engines === 'object') {
253
+ if (record.engines[preferredEngine]) candidateSlots.push([preferredEngine, record.engines[preferredEngine]]);
254
+ for (const [engineName, slot] of Object.entries(record.engines)) {
255
+ if (engineName === preferredEngine) continue;
256
+ candidateSlots.push([engineName, slot]);
257
+ }
258
+ } else if (record.engine) {
259
+ candidateSlots.push([String(record.engine).toLowerCase(), record]);
260
+ }
261
+
262
+ for (const [engineName, slot] of candidateSlots) {
263
+ if (!slot) continue;
264
+ if (slot.id || record.cwd || slot.runtimeSessionObserved === false) {
265
+ return { record, slot, sessionChatId: candidateId, engine: engineName || preferredEngine };
266
+ }
267
+ }
268
+ }
269
+ return null;
270
+ }
271
+
227
272
  function getBoundProjectForChat(chatId, cfg) {
228
273
  const map = {
229
274
  ...(cfg.telegram ? cfg.telegram.chat_agent_map : {}),
@@ -445,6 +490,13 @@ function createCommandRouter(deps) {
445
490
  const workspaceDir = extractPathFromText(input);
446
491
  const hasWorkspacePath = !!workspaceDir;
447
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
+
448
500
  const hasAgentContext = /(agent|智能体|工作区|人设|绑定|当前群|这个群|chat|workspace)/i.test(input);
449
501
  const wantsList = /(列出|查看|显示|有哪些|list|show)/i.test(input) && /(agent|智能体|工作区|绑定)/i.test(input);
450
502
  const wantsUnbind = /(解绑|取消绑定|断开绑定|unbind|unassign)/i.test(input) && hasAgentContext;
@@ -574,7 +626,7 @@ function createCommandRouter(deps) {
574
626
  return false;
575
627
  }
576
628
 
577
- async function handleCommand(bot, chatId, text, config, executeTaskByName, senderId = null, readOnly = false) {
629
+ async function handleCommand(bot, chatId, text, config, executeTaskByName, senderId = null, readOnly = false, meta = {}) {
578
630
  if (text && !text.startsWith('/chatid') && !text.startsWith('/myid')) log('INFO', `CMD [${String(chatId).slice(-8)}]: ${text.slice(0, 80)}`);
579
631
  const state = loadState();
580
632
 
@@ -604,20 +656,36 @@ function createCommandRouter(deps) {
604
656
  ...(config.siri_bridge ? config.siri_bridge.chat_agent_map : {}),
605
657
  };
606
658
  const _chatIdStr = String(chatId);
659
+ const _rawChatId2 = extractOriginalChatId(_chatIdStr);
607
660
  const mappedKey = chatAgentMap[_chatIdStr] ||
661
+ chatAgentMap[_rawChatId2] ||
608
662
  projectKeyFromVirtualChatId(_chatIdStr);
609
663
  if (mappedKey && config.projects && config.projects[mappedKey]) {
610
664
  const proj = config.projects[mappedKey];
611
665
  const projCwd = normalizeCwd(proj.cwd);
612
666
  const sessionChatId = buildSessionChatId(chatId, mappedKey);
613
- const cur = loadState().sessions?.[sessionChatId];
667
+ const sessions = loadState().sessions || {};
668
+ const cur = sessions[sessionChatId];
669
+ const rawSession = sessions[String(chatId)];
614
670
  const projEngine = String((proj && proj.engine) || getDefaultEngine()).toLowerCase();
615
671
  // Multi-engine format stores engines in cur.engines object; legacy format uses cur.engine string.
616
672
  // Check whether the session already has a slot for the project's configured engine.
617
673
  const curHasEngine = cur && (
618
674
  cur.engines ? !!cur.engines[projEngine] : String(cur.engine || '').toLowerCase() === projEngine
619
675
  );
620
- if (!cur || cur.cwd !== projCwd || !curHasEngine) {
676
+ const rawHasEngine = rawSession && (
677
+ rawSession.engines ? !!rawSession.engines[projEngine] : String(rawSession.engine || '').toLowerCase() === projEngine
678
+ );
679
+ const isVirtualSession = _chatIdStr.startsWith('_agent_') || _chatIdStr.startsWith('_scope_');
680
+ const shouldReattachForCwdChange =
681
+ !isVirtualSession &&
682
+ !!cur &&
683
+ !!curHasEngine &&
684
+ cur.cwd !== projCwd &&
685
+ !rawHasEngine;
686
+ if (!cur || !curHasEngine || shouldReattachForCwdChange) {
687
+ const initReason = !cur ? 'no-session' : (!curHasEngine ? 'engine-missing' : 'cwd-changed');
688
+ log('INFO', `SESSION-INIT [${String(sessionChatId).slice(-32)}] ${initReason}`);
621
689
  attachOrCreateSession(sessionChatId, projCwd, proj.name || mappedKey, proj.engine || getDefaultEngine());
622
690
  }
623
691
  }
@@ -722,10 +790,15 @@ function createCommandRouter(deps) {
722
790
  // "继续" when no task running → resume most recent session via /last, then send prompt
723
791
  const CONTINUE_RE = /^(继续|接着|go\s*on|continue)$/i;
724
792
  if (!activeProcesses.has(chatId) && CONTINUE_RE.test(text.trim())) {
725
- // Delegate to /last which attaches the most recent session
793
+ const currentSession = resolveCurrentSessionContext(chatId, config);
794
+ if (currentSession) {
795
+ resetCooldown(chatId);
796
+ await askClaude(bot, chatId, '继续上面的工作', config, readOnly, senderId);
797
+ return;
798
+ }
799
+ // No current session bound to this chat — delegate to /last as a fallback.
726
800
  const handled = await handleSessionCommand({ bot, chatId, text: '/last' });
727
801
  if (handled) {
728
- // /last attached the session — now send "继续" to actually resume the conversation
729
802
  resetCooldown(chatId);
730
803
  await askClaude(bot, chatId, '继续上面的工作', config, readOnly, senderId);
731
804
  return;
@@ -740,7 +813,8 @@ function createCommandRouter(deps) {
740
813
  ...(config.imessage ? config.imessage.chat_agent_map : {}),
741
814
  ...(config.siri_bridge ? config.siri_bridge.chat_agent_map : {}),
742
815
  };
743
- const _isStrictChat = !!(_strictChatAgentMap[String(chatId)] || projectKeyFromVirtualChatId(String(chatId)));
816
+ const _rawChatId3 = extractOriginalChatId(String(chatId));
817
+ const _isStrictChat = !!(_strictChatAgentMap[String(chatId)] || _strictChatAgentMap[_rawChatId3] || projectKeyFromVirtualChatId(String(chatId)));
744
818
 
745
819
  // Nickname-only switch: bypass cooldown + budget (no Claude call)
746
820
  // Skipped for strict chats (fixed-agent groups)
@@ -15,6 +15,19 @@ const CODEX_TOOL_MAP = Object.freeze({
15
15
  web_fetch: 'WebFetch',
16
16
  });
17
17
 
18
+ const ENGINE_TIMEOUT_DEFAULTS = Object.freeze({
19
+ codex: Object.freeze({
20
+ idleMs: 10 * 60 * 1000,
21
+ toolMs: 25 * 60 * 1000,
22
+ ceilingMs: null,
23
+ }),
24
+ claude: Object.freeze({
25
+ idleMs: 5 * 60 * 1000,
26
+ toolMs: 25 * 60 * 1000,
27
+ ceilingMs: null,
28
+ }),
29
+ });
30
+
18
31
  function resolveBinary(engineName, deps = {}) {
19
32
  const engine = normalizeEngineName(engineName);
20
33
  const home = deps.HOME || os.homedir();
@@ -269,6 +282,12 @@ function parseCodexStreamEvent(line) {
269
282
  return out;
270
283
  }
271
284
 
285
+ function resolveEngineTimeouts(engineName) {
286
+ const engine = normalizeEngineName(engineName);
287
+ const base = ENGINE_TIMEOUT_DEFAULTS[engine] || ENGINE_TIMEOUT_DEFAULTS.claude;
288
+ return { ...base };
289
+ }
290
+
272
291
  function buildClaudeArgs(options = {}) {
273
292
  const { model = ENGINE_MODEL_CONFIG.claude.main, readOnly = false, session = {}, addDirs } = options;
274
293
  const args = ['-p', '--model', model];
@@ -392,7 +411,7 @@ function buildCodexArgs(options = {}) {
392
411
  return args;
393
412
  }
394
413
 
395
- function buildCodexEnv(baseEnv = {}, { metameProject = '', metameSenderId = '' } = {}) {
414
+ function buildCodexEnv(baseEnv = {}, { metameProject = '', metameSenderId = '', cwd = '' } = {}) {
396
415
  const env = { ...baseEnv, METAME_PROJECT: metameProject, METAME_SENDER_ID: String(metameSenderId || '') };
397
416
  const strippedKeys = [
398
417
  'CODEX_THREAD_ID',
@@ -400,6 +419,7 @@ function buildCodexEnv(baseEnv = {}, { metameProject = '', metameSenderId = '' }
400
419
  'CLAUDE_CODE_SSE_PORT',
401
420
  ];
402
421
  for (const key of strippedKeys) delete env[key];
422
+ void cwd;
403
423
  if (env.CODEX_HOME && !fs.existsSync(env.CODEX_HOME)) delete env.CODEX_HOME;
404
424
  return env;
405
425
  }
@@ -421,9 +441,9 @@ function createEngineRuntimeFactory(deps = {}) {
421
441
  defaultModel: ENGINE_MODEL_CONFIG.codex.main,
422
442
  stdinBehavior: 'write-and-close',
423
443
  killSignal: 'SIGTERM',
424
- timeouts: { idleMs: 10 * 60 * 1000, toolMs: 25 * 60 * 1000, ceilingMs: 60 * 60 * 1000 },
444
+ timeouts: resolveEngineTimeouts('codex'),
425
445
  buildArgs: buildCodexArgs,
426
- buildEnv: ({ metameProject = '', metameSenderId = '' } = {}) => buildCodexEnv(process.env, { metameProject, metameSenderId }),
446
+ buildEnv: ({ metameProject = '', metameSenderId = '', cwd = '' } = {}) => buildCodexEnv(process.env, { metameProject, metameSenderId, cwd }),
427
447
  parseStreamEvent: parseCodexStreamEvent,
428
448
  classifyError: classifyEngineError,
429
449
  };
@@ -434,7 +454,7 @@ function createEngineRuntimeFactory(deps = {}) {
434
454
  defaultModel: ENGINE_MODEL_CONFIG.claude.main,
435
455
  stdinBehavior: 'write-and-close',
436
456
  killSignal: 'SIGTERM',
437
- timeouts: { idleMs: 5 * 60 * 1000, toolMs: 25 * 60 * 1000, ceilingMs: 60 * 60 * 1000 },
457
+ timeouts: resolveEngineTimeouts('claude'),
438
458
  buildArgs: buildClaudeArgs,
439
459
  buildEnv: ({ metameProject = '', metameSenderId = '' } = {}) => ({
440
460
  ...(() => {
@@ -460,6 +480,7 @@ module.exports = {
460
480
  ENGINE_DISTILL_MAP,
461
481
  ENGINE_DEFAULT_MODEL,
462
482
  _private: {
483
+ ENGINE_TIMEOUT_DEFAULTS,
463
484
  classifyEngineError,
464
485
  parseClaudeStreamEvent,
465
486
  parseCodexStreamEvent,
@@ -472,5 +493,6 @@ module.exports = {
472
493
  BUILTIN_CLAUDE_MODEL_VALUES,
473
494
  normalizeClaudeModel,
474
495
  looksLikeCodexModel,
496
+ resolveEngineTimeouts,
475
497
  },
476
498
  };
@@ -283,9 +283,9 @@ function createMessagePipeline(deps) {
283
283
  */
284
284
  async function _processOne(chatId, text, ctx) {
285
285
  if (resetCooldown) resetCooldown(chatId);
286
- const { bot, config, executeTaskByName, senderId, readOnly } = ctx;
286
+ const { bot, config, executeTaskByName, senderId, readOnly, meta } = ctx;
287
287
  try {
288
- return await handleCommand(bot, chatId, text, config, executeTaskByName, senderId, readOnly);
288
+ return await handleCommand(bot, chatId, text, config, executeTaskByName, senderId, readOnly, meta || {});
289
289
  } catch (err) {
290
290
  log('ERROR', `Pipeline: error processing message for ${chatId}: ${err.message}`);
291
291
  return { ok: false, error: err.message };
@@ -4,7 +4,9 @@ const path = require('path');
4
4
  const fs = require('fs');
5
5
  const os = require('os');
6
6
  const { execSync } = require('child_process');
7
- const EVENTS_DIR = path.join(os.homedir(), '.metame', 'events');
7
+ const { buildReactivePrompt } = require('./core/reactive-prompt');
8
+ const { calculateNextAction } = require('./core/reactive-signal');
9
+ const { resolveReactivePaths } = require('./core/reactive-paths');
8
10
 
9
11
  /**
10
12
  * daemon-reactive-lifecycle.js — Reactive Loop Lifecycle Module
@@ -16,7 +18,7 @@ const EVENTS_DIR = path.join(os.homedir(), '.metame', 'events');
16
18
  * 3. Fresh session — every reactive dispatch uses new_session: true
17
19
  * 4. Completion signal — configurable per project (default: MISSION_COMPLETE)
18
20
  * 5. Verifier hook — runs project verifier before waking parent
19
- * 6. Event sourcing — all state changes logged to ~/.metame/events/
21
+ * 6. Event sourcing — all state changes logged to ~/.metame/reactive/<key>/
20
22
  */
21
23
 
22
24
  // ── Signal parsing ──────────────────────────────────────────────
@@ -87,6 +89,33 @@ function setReactiveStatus(state, projectKey, status, reason) {
87
89
  rs.updated_at = new Date().toISOString();
88
90
  }
89
91
 
92
+ function isReactiveExecutionActive(projectKey, config, deps) {
93
+ const active = deps && deps.activeProcesses;
94
+ if (!active || typeof active.values !== 'function') return false;
95
+ const key = String(projectKey || '').trim();
96
+ if (!key) return false;
97
+ const parent = config && config.projects ? config.projects[key] : null;
98
+ const memberKeys = new Set(
99
+ Array.isArray(parent && parent.team)
100
+ ? parent.team.map(member => String(member && member.key || '').trim()).filter(Boolean)
101
+ : []
102
+ );
103
+ for (const proc of active.values()) {
104
+ if (!proc || proc.aborted) continue;
105
+ const reactiveProjectKey = String(proc.reactiveProjectKey || '').trim();
106
+ if (reactiveProjectKey && reactiveProjectKey === key) return true;
107
+ const procChatId = String(proc.chatId || proc.logicalChatId || '').trim();
108
+ if (!procChatId) continue;
109
+ if (procChatId === `_agent_${key}`) return true;
110
+ if (procChatId.startsWith('_scope_') && procChatId.endsWith(`__${key}`)) return true;
111
+ for (const memberKey of memberKeys) {
112
+ if (procChatId === `_agent_${memberKey}`) return true;
113
+ if (procChatId.startsWith('_scope_') && procChatId.endsWith(`__${memberKey}`)) return true;
114
+ }
115
+ }
116
+ return false;
117
+ }
118
+
90
119
  /**
91
120
  * Find the reactive parent project key for a given team member.
92
121
  * Returns the parent key string, or null if not found.
@@ -171,6 +200,24 @@ function readPhaseFromState(statePath) {
171
200
  } catch { return ''; }
172
201
  }
173
202
 
203
+ /**
204
+ * Load working memory file for a project.
205
+ * Returns the file content as a string, or empty string if not found.
206
+ *
207
+ * @param {string} projectKey
208
+ * @param {string} [metameDir] - Override ~/.metame path (for testing)
209
+ * @returns {string}
210
+ */
211
+ function loadWorkingMemory(projectKey, metameDir) {
212
+ const base = metameDir || path.join(os.homedir(), '.metame');
213
+ const paths = resolveReactivePaths(projectKey, base);
214
+ const memPath = paths.memory;
215
+ try {
216
+ const content = fs.readFileSync(memPath, 'utf8').trim();
217
+ return content || '';
218
+ } catch { return ''; }
219
+ }
220
+
174
221
  /**
175
222
  * Run project-level verifier script if it exists.
176
223
  * Returns parsed JSON result or null if no verifier / error.
@@ -184,7 +231,8 @@ function runProjectVerifier(projectKey, config, deps) {
184
231
  if (!fs.existsSync(scripts.verifier)) return null;
185
232
 
186
233
  const metameDir = deps.metameDir || path.join(os.homedir(), '.metame');
187
- const statePath = path.join(metameDir, 'memory', 'now', `${projectKey}.md`);
234
+ const paths = resolveReactivePaths(projectKey, metameDir);
235
+ const statePath = paths.state;
188
236
 
189
237
  // Read phase from event log (SoT), fall back to state file for backward compat
190
238
  const { phase: eventPhase } = replayEventLog(projectKey, deps);
@@ -223,10 +271,9 @@ function runCompletionHooks(projectKey, projectCwd, deps) {
223
271
  // 1. Archive (if script exists)
224
272
  if (fs.existsSync(scripts.archiver)) {
225
273
  try {
226
- const statePath = path.join(
227
- deps.metameDir || path.join(os.homedir(), '.metame'),
228
- 'memory', 'now', `${projectKey}.md`
229
- );
274
+ const metameDir = deps.metameDir || path.join(os.homedir(), '.metame');
275
+ const rPaths = resolveReactivePaths(projectKey, metameDir);
276
+ const statePath = rPaths.state;
230
277
  let projectName = projectKey;
231
278
  try {
232
279
  const stateContent = fs.readFileSync(statePath, 'utf8');
@@ -304,12 +351,13 @@ function runCompletionHooks(projectKey, projectCwd, deps) {
304
351
 
305
352
  /**
306
353
  * Append an event to the project's event log.
307
- * Daemon-exclusive: agents cannot write to ~/.metame/events/.
354
+ * Daemon-exclusive: agents cannot write to ~/.metame/reactive/<key>/.
308
355
  */
309
356
  function appendEvent(projectKey, event, metameDir) {
310
- const evDir = metameDir ? path.join(metameDir, 'events') : EVENTS_DIR;
311
- fs.mkdirSync(evDir, { recursive: true });
312
- const logPath = path.join(evDir, `${projectKey}.jsonl`);
357
+ const base = metameDir || path.join(os.homedir(), '.metame');
358
+ const paths = resolveReactivePaths(projectKey, base);
359
+ fs.mkdirSync(paths.dir, { recursive: true });
360
+ const logPath = paths.events;
313
361
  const line = JSON.stringify({ ts: new Date().toISOString(), ...event }) + '\n';
314
362
  fs.appendFileSync(logPath, line, 'utf8');
315
363
  }
@@ -323,8 +371,9 @@ function appendEvent(projectKey, event, metameDir) {
323
371
  * This function NEVER throws.
324
372
  */
325
373
  function replayEventLog(projectKey, deps) {
326
- const evDir = deps?.metameDir ? path.join(deps.metameDir, 'events') : EVENTS_DIR;
327
- const logPath = path.join(evDir, `${projectKey}.jsonl`);
374
+ const base = deps?.metameDir || path.join(os.homedir(), '.metame');
375
+ const paths = resolveReactivePaths(projectKey, base);
376
+ const logPath = paths.events;
328
377
  if (!fs.existsSync(logPath)) return { phase: '', mission: null, history: [] };
329
378
 
330
379
  const lines = fs.readFileSync(logPath, 'utf8').trim().split('\n').filter(Boolean);
@@ -365,8 +414,9 @@ function projectProgressTsv(projectCwd, projectKey, metameDir) {
365
414
  const tsvPath = path.join(projectCwd, 'workspace', 'progress.tsv');
366
415
  const header = 'phase\tresult\tverifier_passed\tartifact\ttimestamp\tnotes\n';
367
416
 
368
- const evDir = metameDir ? path.join(metameDir, 'events') : EVENTS_DIR;
369
- const logPath = path.join(evDir, `${projectKey}.jsonl`);
417
+ const base = metameDir || path.join(os.homedir(), '.metame');
418
+ const paths = resolveReactivePaths(projectKey, base);
419
+ const logPath = paths.events;
370
420
  if (!fs.existsSync(logPath)) return;
371
421
 
372
422
  const lines = fs.readFileSync(logPath, 'utf8').trim().split('\n').filter(Boolean);
@@ -403,7 +453,8 @@ function projectProgressTsv(projectCwd, projectKey, metameDir) {
403
453
  */
404
454
  function generateStateFile(projectKey, config, deps) {
405
455
  const metameDir = deps.metameDir || path.join(os.homedir(), '.metame');
406
- const statePath = path.join(metameDir, 'memory', 'now', projectKey + '.md');
456
+ const paths = resolveReactivePaths(projectKey, metameDir);
457
+ const statePath = paths.state;
407
458
 
408
459
  const { phase, mission, history } = replayEventLog(projectKey, deps);
409
460
 
@@ -457,6 +508,10 @@ function reconcilePerpetualProjects(config, deps) {
457
508
  const staleThreshold = staleMinutes * 60 * 1000;
458
509
 
459
510
  if (Date.now() - lastUpdate > staleThreshold) {
511
+ if (isReactiveExecutionActive(key, config, deps)) {
512
+ deps.log('INFO', `Reconcile: ${key} exceeds stale threshold but reactive execution is still active`);
513
+ continue;
514
+ }
460
515
  deps.log('WARN', `Reconcile: ${key} stuck since ${rs.updated_at}`);
461
516
  setReactiveStatus(st, key, 'stale', 'no_activity');
462
517
  deps.saveState(st);
@@ -481,8 +536,8 @@ function reconcilePerpetualProjects(config, deps) {
481
536
  */
482
537
  function parseEventLog(projectKey, deps) {
483
538
  const metameDir = deps.metameDir || path.join(os.homedir(), '.metame');
484
- const evDir = path.join(metameDir, 'events');
485
- const logPath = path.join(evDir, `${projectKey}.jsonl`);
539
+ const paths = resolveReactivePaths(projectKey, metameDir);
540
+ const logPath = paths.events;
486
541
  if (!fs.existsSync(logPath)) return [];
487
542
 
488
543
  const raw = fs.readFileSync(logPath, 'utf8').trim().split('\n').filter(Boolean);
@@ -689,9 +744,9 @@ function buildWorkingMemory(projectKey, config, deps) {
689
744
  */
690
745
  function persistMemoryFiles(projectKey, config, deps, opts = {}) {
691
746
  const metameDir = deps.metameDir || path.join(os.homedir(), '.metame');
692
- const memDir = path.join(metameDir, 'memory', 'now');
693
- fs.mkdirSync(memDir, { recursive: true });
694
- const memPath = path.join(memDir, `${projectKey}_memory.md`);
747
+ const paths = resolveReactivePaths(projectKey, metameDir);
748
+ fs.mkdirSync(paths.dir, { recursive: true });
749
+ const memPath = paths.memory;
695
750
 
696
751
  // Single parse of event log — shared across L1 and round counting
697
752
  const events = parseEventLog(projectKey, deps);
@@ -724,15 +779,13 @@ function persistMemoryFiles(projectKey, config, deps, opts = {}) {
724
779
  l2 = buildWorkingMemory(projectKey, config, deps);
725
780
  // Stash L2 for next time
726
781
  try {
727
- const l2CachePath = path.join(memDir, `${projectKey}_l2cache.md`);
728
- fs.writeFileSync(l2CachePath, l2, 'utf8');
782
+ fs.writeFileSync(paths.l2cache, l2, 'utf8');
729
783
  } catch { /* non-critical */ }
730
784
  } else {
731
785
  // Read stale L2 from cache
732
786
  try {
733
- const l2CachePath = path.join(memDir, `${projectKey}_l2cache.md`);
734
- if (fs.existsSync(l2CachePath)) {
735
- l2 = fs.readFileSync(l2CachePath, 'utf8').trim();
787
+ if (fs.existsSync(paths.l2cache)) {
788
+ l2 = fs.readFileSync(paths.l2cache, 'utf8').trim();
736
789
  }
737
790
  } catch { /* non-critical */ }
738
791
  }
@@ -880,12 +933,51 @@ function handleReactiveOutput(targetProject, output, config, deps) {
880
933
 
881
934
  // ── Case 1: targetProject is a reactive parent ──
882
935
  if (isReactiveParent(targetProject, config)) {
883
- if (!hasSignals) return;
884
-
885
936
  const projectKey = targetProject;
886
937
  const pName = config.projects[projectKey]?.name || projectKey;
887
938
  const st = deps.loadState();
888
939
  const rs = getReactiveState(st, projectKey);
940
+ const maxRetries = manifest?.no_signal_max_retries || 3;
941
+
942
+ const decision = calculateNextAction({
943
+ hasSignals,
944
+ isComplete: signals.complete,
945
+ noSignalCount: rs._no_signal_count || 0,
946
+ maxRetries,
947
+ });
948
+
949
+ rs._no_signal_count = decision.nextNoSignalCount;
950
+
951
+ if (decision.action === 'pause') {
952
+ setReactiveStatus(st, projectKey, 'paused', decision.pauseReason);
953
+ deps.saveState(st);
954
+ logEvent(projectKey, { type: 'NO_SIGNAL_PAUSE', count: decision.nextNoSignalCount });
955
+ if (deps.notifyUser) {
956
+ deps.notifyUser(`\u26a0\ufe0f ${pName} continuous ${maxRetries} rounds without signal, paused`);
957
+ }
958
+ return;
959
+ }
960
+
961
+ if (decision.action === 'retry') {
962
+ deps.saveState(st);
963
+ logEvent(projectKey, { type: 'NO_SIGNAL_RETRY', count: decision.nextNoSignalCount, output_length: output.length });
964
+ const maxDepthForRetry = manifest?.max_depth || rs.max_depth || 50;
965
+ const workingMemory = loadWorkingMemory(projectKey, deps.metameDir);
966
+ deps.handleDispatchItem({
967
+ target: projectKey,
968
+ prompt: buildReactivePrompt(
969
+ 'Check progress and continue executing the task.',
970
+ { depth: rs.depth, maxDepth: maxDepthForRetry, completionSignal, workingMemory, isRetry: true }
971
+ ),
972
+ from: '_system',
973
+ _reactive: true,
974
+ _reactive_project: projectKey,
975
+ new_session: true,
976
+ }, config);
977
+ return;
978
+ }
979
+
980
+ // decision.action === 'proceed' — reset no-signal count, continue normal flow
889
981
 
890
982
  // Mission complete takes priority
891
983
  if (signals.complete) {
@@ -925,6 +1017,7 @@ function handleReactiveOutput(targetProject, output, config, deps) {
925
1017
  prompt: completionResult.nextMissionPrompt,
926
1018
  from: '_system',
927
1019
  _reactive: true,
1020
+ _reactive_project: projectKey,
928
1021
  new_session: true,
929
1022
  }, config);
930
1023
  }
@@ -965,13 +1058,17 @@ function handleReactiveOutput(targetProject, output, config, deps) {
965
1058
  deps.saveState(st);
966
1059
 
967
1060
  // Dispatch each directive with fresh session
1061
+ const workingMemory = loadWorkingMemory(projectKey, deps.metameDir);
968
1062
  for (const d of signals.directives) {
969
1063
  logEvent(projectKey, { type: 'DISPATCH', target: d.target, prompt: d.prompt.slice(0, 200) });
970
1064
  deps.handleDispatchItem({
971
1065
  target: d.target,
972
- prompt: d.prompt,
1066
+ prompt: buildReactivePrompt(d.prompt, {
1067
+ depth: rs.depth, maxDepth, completionSignal, workingMemory,
1068
+ }),
973
1069
  from: projectKey,
974
1070
  _reactive: true,
1071
+ _reactive_project: projectKey,
975
1072
  new_session: true,
976
1073
  }, config);
977
1074
  }
@@ -1001,8 +1098,7 @@ function handleReactiveOutput(targetProject, output, config, deps) {
1001
1098
 
1002
1099
  // Depth gate (manifest max_depth overrides default)
1003
1100
  const rs = getReactiveState(st, parentKey);
1004
- const parentManifestForDepth = projectCwd ? loadProjectManifest(projectCwd) : null;
1005
- const maxDepth = parentManifestForDepth?.max_depth || rs.max_depth || 50;
1101
+ const maxDepth = manifest?.max_depth || rs.max_depth || 50;
1006
1102
  if (rs.depth >= maxDepth) {
1007
1103
  deps.log('WARN', `Reactive: depth ${rs.depth} >= ${maxDepth}, pausing ${parentKey} (via member ${targetProject})`);
1008
1104
  setReactiveStatus(st, parentKey, 'paused', 'depth_exceeded');
@@ -1084,11 +1180,16 @@ function handleReactiveOutput(targetProject, output, config, deps) {
1084
1180
  const parentManifest = parentCwd ? loadProjectManifest(parentCwd) : null;
1085
1181
  const signal = parentManifest?.completion_signal || 'MISSION_COMPLETE';
1086
1182
  const summary = extractOutputSummary(output);
1183
+ const parentWorkingMemory = loadWorkingMemory(parentKey, deps.metameDir);
1087
1184
  deps.handleDispatchItem({
1088
1185
  target: parentKey,
1089
- prompt: `[${targetProject} delivery]${verifierBlock}\n\n${summary}\n\nDecide next step. Use NEXT_DISPATCH or ${signal}.`,
1186
+ prompt: buildReactivePrompt(
1187
+ `[${targetProject} delivery]${verifierBlock}\n\n${summary}\n\nDecide next step.`,
1188
+ { depth: rs.depth, maxDepth: manifest?.max_depth || rs.max_depth || 50, completionSignal: signal, workingMemory: parentWorkingMemory }
1189
+ ),
1090
1190
  from: targetProject,
1091
1191
  _reactive: true,
1192
+ _reactive_project: parentKey,
1092
1193
  new_session: true,
1093
1194
  }, config);
1094
1195
  }
@@ -1098,5 +1199,5 @@ module.exports = {
1098
1199
  parseReactiveSignals,
1099
1200
  reconcilePerpetualProjects,
1100
1201
  replayEventLog,
1101
- __test: { runProjectVerifier, readPhaseFromState, resolveProjectCwd, appendEvent, projectProgressTsv, generateStateFile, loadProjectManifest, resolveProjectScripts, parseEventLog, buildRunningMemory, scanRelevantArtifacts, buildWorkingMemory, persistMemoryFiles, extractInlineFacts, extractOutputSummary },
1202
+ __test: { runProjectVerifier, readPhaseFromState, resolveProjectCwd, appendEvent, projectProgressTsv, generateStateFile, loadProjectManifest, resolveProjectScripts, parseEventLog, buildRunningMemory, scanRelevantArtifacts, buildWorkingMemory, persistMemoryFiles, extractInlineFacts, extractOutputSummary, isReactiveExecutionActive, loadWorkingMemory },
1102
1203
  };