metame-cli 1.5.19 → 1.5.21

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 (40) 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 +92 -38
  18. package/scripts/daemon-claude-engine.js +373 -444
  19. package/scripts/daemon-command-router.js +82 -8
  20. package/scripts/daemon-engine-runtime.js +7 -10
  21. package/scripts/daemon-reactive-lifecycle.js +100 -33
  22. package/scripts/daemon-session-commands.js +133 -43
  23. package/scripts/daemon-session-store.js +300 -82
  24. package/scripts/daemon-team-dispatch.js +16 -16
  25. package/scripts/daemon.js +21 -175
  26. package/scripts/deploy-manifest.js +90 -0
  27. package/scripts/docs/maintenance-manual.md +14 -11
  28. package/scripts/docs/pointer-map.md +13 -4
  29. package/scripts/feishu-adapter.js +31 -27
  30. package/scripts/hooks/intent-engine.js +6 -3
  31. package/scripts/hooks/intent-memory-recall.js +1 -0
  32. package/scripts/hooks/intent-perpetual.js +1 -1
  33. package/scripts/memory-extract.js +5 -97
  34. package/scripts/memory-gc.js +35 -90
  35. package/scripts/memory-migrate-v2.js +304 -0
  36. package/scripts/memory-nightly-reflect.js +40 -41
  37. package/scripts/memory.js +340 -859
  38. package/scripts/migrate-reactive-paths.js +122 -0
  39. package/scripts/signal-capture.js +4 -0
  40. 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;
@@ -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
  }
@@ -654,7 +722,7 @@ function createCommandRouter(deps) {
654
722
  }
655
723
  const btwPrompt = `[Side question — answer concisely from existing context, no need for tools]\n\n${btwQuestion}`;
656
724
  resetCooldown(chatId);
657
- await askClaude(bot, chatId, btwPrompt, config, true, senderId, meta);
725
+ await askClaude(bot, chatId, btwPrompt, config, true, senderId);
658
726
  return;
659
727
  }
660
728
 
@@ -722,12 +790,17 @@ 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
- await askClaude(bot, chatId, '继续上面的工作', config, readOnly, senderId, meta);
803
+ await askClaude(bot, chatId, '继续上面的工作', config, readOnly, senderId);
731
804
  return;
732
805
  }
733
806
  // No session found — fall through to normal askClaude
@@ -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)
@@ -775,7 +849,7 @@ function createCommandRouter(deps) {
775
849
  await bot.sendMessage(chatId, 'Daily token budget exceeded.');
776
850
  return;
777
851
  }
778
- const claudeResult = await askClaude(bot, chatId, text, config, readOnly, senderId, meta);
852
+ const claudeResult = await askClaude(bot, chatId, text, config, readOnly, senderId);
779
853
  const claudeFailed = !!(claudeResult && claudeResult.ok === false);
780
854
  const claudeAborted = !!(claudeResult && claudeResult.error === 'Stopped by user');
781
855
  if (claudeFailed && !claudeAborted && !macLocalFirst && macFallbackEnabled && allowLocalMacControl) {
@@ -19,12 +19,12 @@ const ENGINE_TIMEOUT_DEFAULTS = Object.freeze({
19
19
  codex: Object.freeze({
20
20
  idleMs: 10 * 60 * 1000,
21
21
  toolMs: 25 * 60 * 1000,
22
- ceilingMs: 60 * 60 * 1000,
22
+ ceilingMs: null,
23
23
  }),
24
24
  claude: Object.freeze({
25
25
  idleMs: 5 * 60 * 1000,
26
26
  toolMs: 25 * 60 * 1000,
27
- ceilingMs: 60 * 60 * 1000,
27
+ ceilingMs: null,
28
28
  }),
29
29
  });
30
30
 
@@ -282,14 +282,10 @@ function parseCodexStreamEvent(line) {
282
282
  return out;
283
283
  }
284
284
 
285
- function resolveEngineTimeouts(engineName, opts = {}) {
285
+ function resolveEngineTimeouts(engineName) {
286
286
  const engine = normalizeEngineName(engineName);
287
287
  const base = ENGINE_TIMEOUT_DEFAULTS[engine] || ENGINE_TIMEOUT_DEFAULTS.claude;
288
- if (!opts || !opts.reactive) return { ...base };
289
- return {
290
- ...base,
291
- ceilingMs: null,
292
- };
288
+ return { ...base };
293
289
  }
294
290
 
295
291
  function buildClaudeArgs(options = {}) {
@@ -415,7 +411,7 @@ function buildCodexArgs(options = {}) {
415
411
  return args;
416
412
  }
417
413
 
418
- function buildCodexEnv(baseEnv = {}, { metameProject = '', metameSenderId = '' } = {}) {
414
+ function buildCodexEnv(baseEnv = {}, { metameProject = '', metameSenderId = '', cwd = '' } = {}) {
419
415
  const env = { ...baseEnv, METAME_PROJECT: metameProject, METAME_SENDER_ID: String(metameSenderId || '') };
420
416
  const strippedKeys = [
421
417
  'CODEX_THREAD_ID',
@@ -423,6 +419,7 @@ function buildCodexEnv(baseEnv = {}, { metameProject = '', metameSenderId = '' }
423
419
  'CLAUDE_CODE_SSE_PORT',
424
420
  ];
425
421
  for (const key of strippedKeys) delete env[key];
422
+ void cwd;
426
423
  if (env.CODEX_HOME && !fs.existsSync(env.CODEX_HOME)) delete env.CODEX_HOME;
427
424
  return env;
428
425
  }
@@ -446,7 +443,7 @@ function createEngineRuntimeFactory(deps = {}) {
446
443
  killSignal: 'SIGTERM',
447
444
  timeouts: resolveEngineTimeouts('codex'),
448
445
  buildArgs: buildCodexArgs,
449
- buildEnv: ({ metameProject = '', metameSenderId = '' } = {}) => buildCodexEnv(process.env, { metameProject, metameSenderId }),
446
+ buildEnv: ({ metameProject = '', metameSenderId = '', cwd = '' } = {}) => buildCodexEnv(process.env, { metameProject, metameSenderId, cwd }),
450
447
  parseStreamEvent: parseCodexStreamEvent,
451
448
  classifyError: classifyEngineError,
452
449
  };
@@ -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 ──────────────────────────────────────────────
@@ -198,6 +200,24 @@ function readPhaseFromState(statePath) {
198
200
  } catch { return ''; }
199
201
  }
200
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
+
201
221
  /**
202
222
  * Run project-level verifier script if it exists.
203
223
  * Returns parsed JSON result or null if no verifier / error.
@@ -211,7 +231,8 @@ function runProjectVerifier(projectKey, config, deps) {
211
231
  if (!fs.existsSync(scripts.verifier)) return null;
212
232
 
213
233
  const metameDir = deps.metameDir || path.join(os.homedir(), '.metame');
214
- const statePath = path.join(metameDir, 'memory', 'now', `${projectKey}.md`);
234
+ const paths = resolveReactivePaths(projectKey, metameDir);
235
+ const statePath = paths.state;
215
236
 
216
237
  // Read phase from event log (SoT), fall back to state file for backward compat
217
238
  const { phase: eventPhase } = replayEventLog(projectKey, deps);
@@ -250,10 +271,9 @@ function runCompletionHooks(projectKey, projectCwd, deps) {
250
271
  // 1. Archive (if script exists)
251
272
  if (fs.existsSync(scripts.archiver)) {
252
273
  try {
253
- const statePath = path.join(
254
- deps.metameDir || path.join(os.homedir(), '.metame'),
255
- 'memory', 'now', `${projectKey}.md`
256
- );
274
+ const metameDir = deps.metameDir || path.join(os.homedir(), '.metame');
275
+ const rPaths = resolveReactivePaths(projectKey, metameDir);
276
+ const statePath = rPaths.state;
257
277
  let projectName = projectKey;
258
278
  try {
259
279
  const stateContent = fs.readFileSync(statePath, 'utf8');
@@ -331,12 +351,13 @@ function runCompletionHooks(projectKey, projectCwd, deps) {
331
351
 
332
352
  /**
333
353
  * Append an event to the project's event log.
334
- * Daemon-exclusive: agents cannot write to ~/.metame/events/.
354
+ * Daemon-exclusive: agents cannot write to ~/.metame/reactive/<key>/.
335
355
  */
336
356
  function appendEvent(projectKey, event, metameDir) {
337
- const evDir = metameDir ? path.join(metameDir, 'events') : EVENTS_DIR;
338
- fs.mkdirSync(evDir, { recursive: true });
339
- 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;
340
361
  const line = JSON.stringify({ ts: new Date().toISOString(), ...event }) + '\n';
341
362
  fs.appendFileSync(logPath, line, 'utf8');
342
363
  }
@@ -350,8 +371,9 @@ function appendEvent(projectKey, event, metameDir) {
350
371
  * This function NEVER throws.
351
372
  */
352
373
  function replayEventLog(projectKey, deps) {
353
- const evDir = deps?.metameDir ? path.join(deps.metameDir, 'events') : EVENTS_DIR;
354
- 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;
355
377
  if (!fs.existsSync(logPath)) return { phase: '', mission: null, history: [] };
356
378
 
357
379
  const lines = fs.readFileSync(logPath, 'utf8').trim().split('\n').filter(Boolean);
@@ -392,8 +414,9 @@ function projectProgressTsv(projectCwd, projectKey, metameDir) {
392
414
  const tsvPath = path.join(projectCwd, 'workspace', 'progress.tsv');
393
415
  const header = 'phase\tresult\tverifier_passed\tartifact\ttimestamp\tnotes\n';
394
416
 
395
- const evDir = metameDir ? path.join(metameDir, 'events') : EVENTS_DIR;
396
- 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;
397
420
  if (!fs.existsSync(logPath)) return;
398
421
 
399
422
  const lines = fs.readFileSync(logPath, 'utf8').trim().split('\n').filter(Boolean);
@@ -430,7 +453,8 @@ function projectProgressTsv(projectCwd, projectKey, metameDir) {
430
453
  */
431
454
  function generateStateFile(projectKey, config, deps) {
432
455
  const metameDir = deps.metameDir || path.join(os.homedir(), '.metame');
433
- const statePath = path.join(metameDir, 'memory', 'now', projectKey + '.md');
456
+ const paths = resolveReactivePaths(projectKey, metameDir);
457
+ const statePath = paths.state;
434
458
 
435
459
  const { phase, mission, history } = replayEventLog(projectKey, deps);
436
460
 
@@ -512,8 +536,8 @@ function reconcilePerpetualProjects(config, deps) {
512
536
  */
513
537
  function parseEventLog(projectKey, deps) {
514
538
  const metameDir = deps.metameDir || path.join(os.homedir(), '.metame');
515
- const evDir = path.join(metameDir, 'events');
516
- const logPath = path.join(evDir, `${projectKey}.jsonl`);
539
+ const paths = resolveReactivePaths(projectKey, metameDir);
540
+ const logPath = paths.events;
517
541
  if (!fs.existsSync(logPath)) return [];
518
542
 
519
543
  const raw = fs.readFileSync(logPath, 'utf8').trim().split('\n').filter(Boolean);
@@ -720,9 +744,9 @@ function buildWorkingMemory(projectKey, config, deps) {
720
744
  */
721
745
  function persistMemoryFiles(projectKey, config, deps, opts = {}) {
722
746
  const metameDir = deps.metameDir || path.join(os.homedir(), '.metame');
723
- const memDir = path.join(metameDir, 'memory', 'now');
724
- fs.mkdirSync(memDir, { recursive: true });
725
- 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;
726
750
 
727
751
  // Single parse of event log — shared across L1 and round counting
728
752
  const events = parseEventLog(projectKey, deps);
@@ -755,15 +779,13 @@ function persistMemoryFiles(projectKey, config, deps, opts = {}) {
755
779
  l2 = buildWorkingMemory(projectKey, config, deps);
756
780
  // Stash L2 for next time
757
781
  try {
758
- const l2CachePath = path.join(memDir, `${projectKey}_l2cache.md`);
759
- fs.writeFileSync(l2CachePath, l2, 'utf8');
782
+ fs.writeFileSync(paths.l2cache, l2, 'utf8');
760
783
  } catch { /* non-critical */ }
761
784
  } else {
762
785
  // Read stale L2 from cache
763
786
  try {
764
- const l2CachePath = path.join(memDir, `${projectKey}_l2cache.md`);
765
- if (fs.existsSync(l2CachePath)) {
766
- l2 = fs.readFileSync(l2CachePath, 'utf8').trim();
787
+ if (fs.existsSync(paths.l2cache)) {
788
+ l2 = fs.readFileSync(paths.l2cache, 'utf8').trim();
767
789
  }
768
790
  } catch { /* non-critical */ }
769
791
  }
@@ -911,12 +933,51 @@ function handleReactiveOutput(targetProject, output, config, deps) {
911
933
 
912
934
  // ── Case 1: targetProject is a reactive parent ──
913
935
  if (isReactiveParent(targetProject, config)) {
914
- if (!hasSignals) return;
915
-
916
936
  const projectKey = targetProject;
917
937
  const pName = config.projects[projectKey]?.name || projectKey;
918
938
  const st = deps.loadState();
919
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
920
981
 
921
982
  // Mission complete takes priority
922
983
  if (signals.complete) {
@@ -997,11 +1058,14 @@ function handleReactiveOutput(targetProject, output, config, deps) {
997
1058
  deps.saveState(st);
998
1059
 
999
1060
  // Dispatch each directive with fresh session
1061
+ const workingMemory = loadWorkingMemory(projectKey, deps.metameDir);
1000
1062
  for (const d of signals.directives) {
1001
1063
  logEvent(projectKey, { type: 'DISPATCH', target: d.target, prompt: d.prompt.slice(0, 200) });
1002
1064
  deps.handleDispatchItem({
1003
1065
  target: d.target,
1004
- prompt: d.prompt,
1066
+ prompt: buildReactivePrompt(d.prompt, {
1067
+ depth: rs.depth, maxDepth, completionSignal, workingMemory,
1068
+ }),
1005
1069
  from: projectKey,
1006
1070
  _reactive: true,
1007
1071
  _reactive_project: projectKey,
@@ -1034,8 +1098,7 @@ function handleReactiveOutput(targetProject, output, config, deps) {
1034
1098
 
1035
1099
  // Depth gate (manifest max_depth overrides default)
1036
1100
  const rs = getReactiveState(st, parentKey);
1037
- const parentManifestForDepth = projectCwd ? loadProjectManifest(projectCwd) : null;
1038
- const maxDepth = parentManifestForDepth?.max_depth || rs.max_depth || 50;
1101
+ const maxDepth = manifest?.max_depth || rs.max_depth || 50;
1039
1102
  if (rs.depth >= maxDepth) {
1040
1103
  deps.log('WARN', `Reactive: depth ${rs.depth} >= ${maxDepth}, pausing ${parentKey} (via member ${targetProject})`);
1041
1104
  setReactiveStatus(st, parentKey, 'paused', 'depth_exceeded');
@@ -1117,9 +1180,13 @@ function handleReactiveOutput(targetProject, output, config, deps) {
1117
1180
  const parentManifest = parentCwd ? loadProjectManifest(parentCwd) : null;
1118
1181
  const signal = parentManifest?.completion_signal || 'MISSION_COMPLETE';
1119
1182
  const summary = extractOutputSummary(output);
1183
+ const parentWorkingMemory = loadWorkingMemory(parentKey, deps.metameDir);
1120
1184
  deps.handleDispatchItem({
1121
1185
  target: parentKey,
1122
- 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
+ ),
1123
1190
  from: targetProject,
1124
1191
  _reactive: true,
1125
1192
  _reactive_project: parentKey,
@@ -1132,5 +1199,5 @@ module.exports = {
1132
1199
  parseReactiveSignals,
1133
1200
  reconcilePerpetualProjects,
1134
1201
  replayEventLog,
1135
- __test: { runProjectVerifier, readPhaseFromState, resolveProjectCwd, appendEvent, projectProgressTsv, generateStateFile, loadProjectManifest, resolveProjectScripts, parseEventLog, buildRunningMemory, scanRelevantArtifacts, buildWorkingMemory, persistMemoryFiles, extractInlineFacts, extractOutputSummary, isReactiveExecutionActive },
1202
+ __test: { runProjectVerifier, readPhaseFromState, resolveProjectCwd, appendEvent, projectProgressTsv, generateStateFile, loadProjectManifest, resolveProjectScripts, parseEventLog, buildRunningMemory, scanRelevantArtifacts, buildWorkingMemory, persistMemoryFiles, extractInlineFacts, extractOutputSummary, isReactiveExecutionActive, loadWorkingMemory },
1136
1203
  };