metame-cli 1.5.10 → 1.5.12

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 (67) hide show
  1. package/README.md +49 -6
  2. package/index.js +266 -72
  3. package/package.json +7 -3
  4. package/scripts/daemon-admin-commands.js +34 -0
  5. package/scripts/daemon-agent-commands.js +6 -2
  6. package/scripts/daemon-bridges.js +41 -10
  7. package/scripts/daemon-claude-engine.js +128 -29
  8. package/scripts/daemon-command-router.js +16 -0
  9. package/scripts/daemon-command-session-route.js +3 -1
  10. package/scripts/daemon-default.yaml +3 -1
  11. package/scripts/daemon-engine-runtime.js +1 -5
  12. package/scripts/daemon-message-pipeline.js +113 -44
  13. package/scripts/daemon-ops-commands.js +25 -11
  14. package/scripts/daemon-reactive-lifecycle.js +757 -76
  15. package/scripts/daemon-session-commands.js +3 -2
  16. package/scripts/daemon-session-store.js +82 -27
  17. package/scripts/daemon-team-dispatch.js +21 -5
  18. package/scripts/daemon-utils.js +3 -1
  19. package/scripts/daemon.js +80 -2
  20. package/scripts/distill.js +1 -1
  21. package/scripts/docs/file-transfer.md +1 -0
  22. package/scripts/docs/maintenance-manual.md +55 -2
  23. package/scripts/docs/pointer-map.md +34 -0
  24. package/scripts/feishu-adapter.js +25 -0
  25. package/scripts/hooks/intent-file-transfer.js +2 -1
  26. package/scripts/hooks/intent-perpetual.js +109 -0
  27. package/scripts/hooks/intent-research.js +112 -0
  28. package/scripts/intent-registry.js +4 -0
  29. package/scripts/memory-extract.js +29 -1
  30. package/scripts/memory-nightly-reflect.js +104 -0
  31. package/scripts/ops-mission-queue.js +258 -0
  32. package/scripts/ops-verifier.js +197 -0
  33. package/scripts/signal-capture.js +3 -3
  34. package/scripts/skill-evolution.js +11 -2
  35. package/skills/agent-browser/SKILL.md +153 -0
  36. package/skills/agent-reach/SKILL.md +66 -0
  37. package/skills/agent-reach/evolution.json +13 -0
  38. package/skills/deep-research/SKILL.md +77 -0
  39. package/skills/find-skills/SKILL.md +133 -0
  40. package/skills/heartbeat-task-manager/SKILL.md +63 -0
  41. package/skills/macos-local-orchestrator/SKILL.md +192 -0
  42. package/skills/macos-local-orchestrator/agents/openai.yaml +4 -0
  43. package/skills/macos-local-orchestrator/references/tooling-landscape.md +70 -0
  44. package/skills/macos-mail-calendar/SKILL.md +394 -0
  45. package/skills/mcp-installer/SKILL.md +138 -0
  46. package/skills/skill-creator/LICENSE.txt +202 -0
  47. package/skills/skill-creator/README.md +72 -0
  48. package/skills/skill-creator/SKILL.md +96 -0
  49. package/skills/skill-creator/evolution.json +6 -0
  50. package/skills/skill-creator/references/creation-guide.md +116 -0
  51. package/skills/skill-creator/references/evolution-guide.md +74 -0
  52. package/skills/skill-creator/references/output-patterns.md +82 -0
  53. package/skills/skill-creator/references/workflows.md +28 -0
  54. package/skills/skill-creator/scripts/align_all.py +32 -0
  55. package/skills/skill-creator/scripts/auto_evolve_hook.js +247 -0
  56. package/skills/skill-creator/scripts/init_skill.py +303 -0
  57. package/skills/skill-creator/scripts/merge_evolution.py +70 -0
  58. package/skills/skill-creator/scripts/package_skill.py +110 -0
  59. package/skills/skill-creator/scripts/quick_validate.py +103 -0
  60. package/skills/skill-creator/scripts/setup.py +141 -0
  61. package/skills/skill-creator/scripts/smart_stitch.py +82 -0
  62. package/skills/skill-manager/SKILL.md +112 -0
  63. package/skills/skill-manager/scripts/delete_skill.py +31 -0
  64. package/skills/skill-manager/scripts/list_skills.py +61 -0
  65. package/skills/skill-manager/scripts/scan_and_check.py +125 -0
  66. package/skills/skill-manager/scripts/sync_index.py +144 -0
  67. package/skills/skill-manager/scripts/update_helper.py +39 -0
@@ -4,17 +4,19 @@ 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
8
 
8
9
  /**
9
10
  * daemon-reactive-lifecycle.js — Reactive Loop Lifecycle Module
10
11
  *
11
- * Extracts reactive dispatch logic from daemon.js into a testable,
12
- * self-contained module with four hard gates:
12
+ * Generic task chain engine for perpetual projects. Domain-agnostic.
13
+ * Hard gates:
13
14
  * 1. Budget gate — pauses loop when daily budget exhausted
14
15
  * 2. Depth gate — pauses loop when depth counter hits max
15
16
  * 3. Fresh session — every reactive dispatch uses new_session: true
16
- * 4. RESEARCH_COMPLETEresets depth, marks completed, notifies user
17
+ * 4. Completion signal configurable per project (default: MISSION_COMPLETE)
17
18
  * 5. Verifier hook — runs project verifier before waking parent
19
+ * 6. Event sourcing — all state changes logged to ~/.metame/events/
18
20
  */
19
21
 
20
22
  // ── Signal parsing ──────────────────────────────────────────────
@@ -34,7 +36,7 @@ const RESEARCH_COMPLETE_RE = /RESEARCH_COMPLETE/;
34
36
  * @param {string} output - Raw agent output text
35
37
  * @returns {{ directives: Array<{target: string, prompt: string}>, complete: boolean }}
36
38
  */
37
- function parseReactiveSignals(output) {
39
+ function parseReactiveSignals(output, completionSignal) {
38
40
  const directives = [];
39
41
  let match;
40
42
  // Try quoted format first (preferred, documented in CLAUDE.md)
@@ -54,7 +56,10 @@ function parseReactiveSignals(output) {
54
56
  if (target && prompt) directives.push({ target, prompt });
55
57
  }
56
58
  }
57
- const complete = RESEARCH_COMPLETE_RE.test(output);
59
+ const completionRe = completionSignal
60
+ ? new RegExp(completionSignal)
61
+ : RESEARCH_COMPLETE_RE;
62
+ const complete = completionRe.test(output);
58
63
  return { directives, complete };
59
64
  }
60
65
 
@@ -107,6 +112,49 @@ function isReactiveParent(projectKey, config) {
107
112
  return !!(proj && proj.reactive);
108
113
  }
109
114
 
115
+ // ── Manifest discovery ──────────────────────────────────────────
116
+
117
+ /**
118
+ * Load project manifest (perpetual.yaml / perpetual.yml) from project root.
119
+ * Returns parsed object or null if not found / parse error.
120
+ *
121
+ * @param {string} projectCwd - Absolute path to project root
122
+ * @returns {object|null}
123
+ */
124
+ function loadProjectManifest(projectCwd) {
125
+ const yaml = require('js-yaml');
126
+ for (const name of ['perpetual.yaml', 'perpetual.yml']) {
127
+ const p = path.join(projectCwd, name);
128
+ if (fs.existsSync(p)) {
129
+ try {
130
+ const content = fs.readFileSync(p, 'utf8');
131
+ return yaml.load(content) || null;
132
+ } catch (e) {
133
+ process.stderr.write(`[reactive-lifecycle] WARN: failed to parse ${p}: ${e.message}\n`);
134
+ return null;
135
+ }
136
+ }
137
+ }
138
+ return null;
139
+ }
140
+
141
+ /**
142
+ * Resolve project script paths using convention-over-configuration.
143
+ * Manifest fields override defaults; all paths are absolute.
144
+ *
145
+ * @param {string} projectCwd - Absolute path to project root
146
+ * @param {object|null} manifest - Parsed perpetual.yaml or null
147
+ * @returns {{ verifier: string, archiver: string, missionQueue: string }}
148
+ */
149
+ function resolveProjectScripts(projectCwd, manifest) {
150
+ const resolve = (override, fallback) => path.join(projectCwd, override || fallback);
151
+ return {
152
+ verifier: resolve(manifest?.verifier, 'scripts/verifier.js'),
153
+ archiver: resolve(manifest?.archiver, 'scripts/archiver.js'),
154
+ missionQueue: resolve(manifest?.mission_queue, 'scripts/mission-queue.js'),
155
+ };
156
+ }
157
+
110
158
  // ── Verifier helpers ────────────────────────────────────────────
111
159
 
112
160
  function resolveProjectCwd(projectKey, config) {
@@ -131,17 +179,20 @@ function runProjectVerifier(projectKey, config, deps) {
131
179
  const projectCwd = resolveProjectCwd(projectKey, config);
132
180
  if (!projectCwd) return null;
133
181
 
134
- const verifierPath = path.join(projectCwd, 'scripts', 'research-verifier.js');
135
- if (!fs.existsSync(verifierPath)) return null;
182
+ const manifest = loadProjectManifest(projectCwd);
183
+ const scripts = resolveProjectScripts(projectCwd, manifest);
184
+ if (!fs.existsSync(scripts.verifier)) return null;
136
185
 
137
- const statePath = path.join(
138
- deps.metameDir || path.join(os.homedir(), '.metame'),
139
- 'memory', 'now', `${projectKey}.md`
140
- );
141
- const phase = readPhaseFromState(statePath);
186
+ const metameDir = deps.metameDir || path.join(os.homedir(), '.metame');
187
+ const statePath = path.join(metameDir, 'memory', 'now', `${projectKey}.md`);
188
+
189
+ // Read phase from event log (SoT), fall back to state file for backward compat
190
+ const { phase: eventPhase } = replayEventLog(projectKey, deps);
191
+ const phase = eventPhase || readPhaseFromState(statePath);
192
+ const relVerifier = path.relative(projectCwd, scripts.verifier);
142
193
 
143
194
  try {
144
- const output = execSync('node scripts/research-verifier.js', {
195
+ const output = execSync(`node "${relVerifier}"`, {
145
196
  cwd: projectCwd,
146
197
  encoding: 'utf8',
147
198
  timeout: 15000,
@@ -155,7 +206,7 @@ function runProjectVerifier(projectKey, config, deps) {
155
206
  return JSON.parse(output);
156
207
  } catch (e) {
157
208
  deps.log('WARN', `Verifier failed for ${projectKey}: ${e.message}`);
158
- return { passed: false, phase: phase || 'unknown', details: `verifier_error: ${e.message.slice(0, 200)}`, artifacts: [], hints: ['验证器执行失败,请检查 verifier 脚本'] };
209
+ return { passed: false, phase: phase || 'unknown', details: `verifier_error: ${e.message.slice(0, 200)}`, artifacts: [], hints: ['Verifier script failed — check scripts/'] };
159
210
  }
160
211
  }
161
212
 
@@ -165,13 +216,13 @@ function runProjectVerifier(projectKey, config, deps) {
165
216
  * @returns {{ archived: boolean, nextTopic: string|null, nextTopicPrompt: string|null }}
166
217
  */
167
218
  function runCompletionHooks(projectKey, projectCwd, deps) {
168
- const result = { archived: false, nextTopic: null, nextTopicPrompt: null };
219
+ const manifest = loadProjectManifest(projectCwd);
220
+ const scripts = resolveProjectScripts(projectCwd, manifest);
221
+ const result = { archived: false, nextMission: null, nextMissionId: null, nextMissionPrompt: null };
169
222
 
170
- // 1. Archive
171
- const archiveScript = path.join(projectCwd, 'scripts', 'research-archive.js');
172
- if (fs.existsSync(archiveScript)) {
223
+ // 1. Archive (if script exists)
224
+ if (fs.existsSync(scripts.archiver)) {
173
225
  try {
174
- // Read project name from state file
175
226
  const statePath = path.join(
176
227
  deps.metameDir || path.join(os.homedir(), '.metame'),
177
228
  'memory', 'now', `${projectKey}.md`
@@ -183,7 +234,8 @@ function runCompletionHooks(projectKey, projectCwd, deps) {
183
234
  if (m) projectName = m[1];
184
235
  } catch { /* use projectKey */ }
185
236
 
186
- const archiveOut = execSync('node scripts/research-archive.js', {
237
+ const relArchiver = path.relative(projectCwd, scripts.archiver);
238
+ const archiveOut = execSync(`node "${relArchiver}"`, {
187
239
  cwd: projectCwd, encoding: 'utf8', timeout: 30000,
188
240
  env: { ...process.env, ARCHIVE_CWD: projectCwd, ARCHIVE_PROJECT_NAME: projectName, ARCHIVE_STATE_PATH: statePath },
189
241
  }).trim();
@@ -195,57 +247,601 @@ function runCompletionHooks(projectKey, projectCwd, deps) {
195
247
  }
196
248
  }
197
249
 
198
- // 2. Topic pool — only proceed if archive succeeded
199
- const topicScript = path.join(projectCwd, 'scripts', 'topic-pool.js');
200
- if (result.archived && fs.existsSync(topicScript)) {
201
- const topicEnv = { ...process.env, TOPICS_CWD: projectCwd };
202
- // 2a. Complete current active topic first
250
+ // 2. Mission queue — only proceed if archive succeeded
251
+ if (result.archived && fs.existsSync(scripts.missionQueue)) {
252
+ const relQueue = path.relative(projectCwd, scripts.missionQueue);
253
+ const queueEnv = { ...process.env, MISSION_CWD: projectCwd, TOPICS_CWD: projectCwd };
254
+ // Sanitize topic IDs to prevent shell injection (only allow alphanumeric, dash, underscore)
255
+ const sanitizeId = (id) => String(id || '').replace(/[^a-zA-Z0-9_-]/g, '');
256
+ // 2a. Complete current active mission
203
257
  try {
204
- const listOut = execSync('node scripts/topic-pool.js list', {
205
- cwd: projectCwd, encoding: 'utf8', timeout: 10000, env: topicEnv,
258
+ const listOut = execSync(`node "${relQueue}" list`, {
259
+ cwd: projectCwd, encoding: 'utf8', timeout: 10000, env: queueEnv,
206
260
  }).trim();
207
261
  const listResult = JSON.parse(listOut);
208
262
  if (listResult.success && Array.isArray(listResult.topics)) {
209
263
  const activeTopic = listResult.topics.find(t => t.status === 'active');
210
264
  if (activeTopic) {
211
- execSync(`node scripts/topic-pool.js complete ${activeTopic.id}`, {
212
- cwd: projectCwd, encoding: 'utf8', timeout: 10000, env: topicEnv,
265
+ execSync(`node "${relQueue}" complete ${sanitizeId(activeTopic.id)}`, {
266
+ cwd: projectCwd, encoding: 'utf8', timeout: 10000, env: queueEnv,
213
267
  });
214
- deps.log('INFO', `Reactive: completed active topic ${activeTopic.id}: ${activeTopic.title}`);
268
+ deps.log('INFO', `Reactive: completed mission ${activeTopic.id}: ${activeTopic.title}`);
215
269
  }
216
270
  }
217
271
  } catch (e) {
218
- deps.log('WARN', `Reactive: topic complete failed: ${e.message}`);
272
+ deps.log('WARN', `Reactive: mission complete failed: ${e.message}`);
219
273
  }
220
- // 2b. Get next pending topic
274
+ // 2b. Get next pending mission
221
275
  try {
222
- const nextOut = execSync('node scripts/topic-pool.js next', {
223
- cwd: projectCwd, encoding: 'utf8', timeout: 10000, env: topicEnv,
276
+ const nextOut = execSync(`node "${relQueue}" next`, {
277
+ cwd: projectCwd, encoding: 'utf8', timeout: 10000, env: queueEnv,
224
278
  }).trim();
225
279
  const nextResult = JSON.parse(nextOut);
226
280
  if (nextResult.success && nextResult.topic) {
227
- // Activate the next topic
228
281
  try {
229
- execSync(`node scripts/topic-pool.js activate ${nextResult.topic.id}`, {
230
- cwd: projectCwd, encoding: 'utf8', timeout: 10000, env: topicEnv,
282
+ execSync(`node "${relQueue}" activate ${sanitizeId(nextResult.topic.id)}`, {
283
+ cwd: projectCwd, encoding: 'utf8', timeout: 10000, env: queueEnv,
231
284
  });
232
285
  } catch (e) {
233
- deps.log('WARN', `Reactive: topic activate failed: ${e.message}`);
286
+ deps.log('WARN', `Reactive: mission activate failed: ${e.message}`);
234
287
  }
235
- result.nextTopic = nextResult.topic.title;
236
- result.nextTopicPrompt = `新课题启动: "${nextResult.topic.title}"\n\n请开始研究这个课题。第一步:更新 now/${projectKey}.md 的 project 和 phase 字段,然后 NEXT_DISPATCH scout 进行文献调研。`;
237
- deps.log('INFO', `Reactive: next topic for ${projectKey}: ${nextResult.topic.title}`);
288
+ result.nextMission = nextResult.topic.title;
289
+ result.nextMissionId = nextResult.topic.id || '';
290
+ 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.`;
291
+ deps.log('INFO', `Reactive: next mission for ${projectKey}: ${nextResult.topic.title}`);
238
292
  }
239
293
  } catch (e) {
240
- deps.log('WARN', `Reactive: topic pool query failed for ${projectKey}: ${e.message}`);
294
+ deps.log('WARN', `Reactive: mission queue query failed for ${projectKey}: ${e.message}`);
241
295
  }
242
- } else if (!result.archived && fs.existsSync(topicScript)) {
243
- deps.log('WARN', `Reactive: skipping topic pool for ${projectKey} — archive did not succeed`);
296
+ } else if (!result.archived && fs.existsSync(scripts.missionQueue)) {
297
+ deps.log('WARN', `Reactive: skipping mission queue for ${projectKey} — archive did not succeed`);
244
298
  }
245
299
 
246
300
  return result;
247
301
  }
248
302
 
303
+ // ── Event Log (Event Sourcing) ──────────────────────────────────
304
+
305
+ /**
306
+ * Append an event to the project's event log.
307
+ * Daemon-exclusive: agents cannot write to ~/.metame/events/.
308
+ */
309
+ 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`);
313
+ const line = JSON.stringify({ ts: new Date().toISOString(), ...event }) + '\n';
314
+ fs.appendFileSync(logPath, line, 'utf8');
315
+ }
316
+
317
+ /**
318
+ * Replay event log to derive current state.
319
+ * Returns { phase, mission, history[] }
320
+ *
321
+ * DESIGN CONTRACT (Tolerant Reader):
322
+ * Malformed lines (e.g. from crash/truncation) are skipped with a WARN log.
323
+ * This function NEVER throws.
324
+ */
325
+ function replayEventLog(projectKey, deps) {
326
+ const evDir = deps?.metameDir ? path.join(deps.metameDir, 'events') : EVENTS_DIR;
327
+ const logPath = path.join(evDir, `${projectKey}.jsonl`);
328
+ if (!fs.existsSync(logPath)) return { phase: '', mission: null, history: [] };
329
+
330
+ const lines = fs.readFileSync(logPath, 'utf8').trim().split('\n').filter(Boolean);
331
+ let phase = '';
332
+ let mission = null;
333
+ const history = [];
334
+
335
+ for (let i = 0; i < lines.length; i++) {
336
+ try {
337
+ const evt = JSON.parse(lines[i]);
338
+ if (evt.type === 'MISSION_START') {
339
+ mission = { id: evt.mission_id, title: evt.mission_title };
340
+ }
341
+ if (evt.type === 'PHASE_GATE' && evt.passed) {
342
+ phase = evt.phase;
343
+ history.push({ phase: evt.phase, date: evt.ts, artifacts: evt.artifacts });
344
+ }
345
+ if (evt.type === 'MISSION_COMPLETE') {
346
+ phase = '';
347
+ mission = null;
348
+ }
349
+ } catch {
350
+ // DESIGN CONTRACT: Tolerant Reader (尾行容错)
351
+ // 断电/Kernel Panic 可能导致最后一行残缺。
352
+ // 逐行 parse,损坏行静默丢弃 + log WARN,绝不 crash loop。
353
+ deps?.log?.('WARN', `Event log ${projectKey} line ${i + 1}: malformed JSON, skipped`);
354
+ }
355
+ }
356
+
357
+ return { phase, mission, history };
358
+ }
359
+
360
+ /**
361
+ * Generate progress.tsv as a human-readable projection of the event log.
362
+ * Not SoT — can be safely regenerated at any time.
363
+ */
364
+ function projectProgressTsv(projectCwd, projectKey, metameDir) {
365
+ const tsvPath = path.join(projectCwd, 'workspace', 'progress.tsv');
366
+ const header = 'phase\tresult\tverifier_passed\tartifact\ttimestamp\tnotes\n';
367
+
368
+ const evDir = metameDir ? path.join(metameDir, 'events') : EVENTS_DIR;
369
+ const logPath = path.join(evDir, `${projectKey}.jsonl`);
370
+ if (!fs.existsSync(logPath)) return;
371
+
372
+ const lines = fs.readFileSync(logPath, 'utf8').trim().split('\n').filter(Boolean);
373
+ let rows = header;
374
+ for (const line of lines) {
375
+ try {
376
+ const evt = JSON.parse(line);
377
+ if (evt.type === 'PHASE_GATE') {
378
+ rows += [
379
+ evt.phase,
380
+ evt.passed ? 'done' : 'in_progress',
381
+ String(evt.passed),
382
+ (evt.artifacts || [])[0] || '',
383
+ evt.ts,
384
+ (evt.details || '').replace(/[\t\n]/g, ' '),
385
+ ].join('\t') + '\n';
386
+ }
387
+ } catch { /* skip */ }
388
+ }
389
+
390
+ fs.mkdirSync(path.dirname(tsvPath), { recursive: true });
391
+ fs.writeFileSync(tsvPath, rows, 'utf8');
392
+ }
393
+
394
+ /**
395
+ * Generate now/<key>.md state file by replaying the event log.
396
+ * This is the canonical way to (re)build the agent-visible state file
397
+ * from the append-only event log (event sourcing projection).
398
+ *
399
+ * @param {string} projectKey
400
+ * @param {object} config - Full daemon config
401
+ * @param {object} deps - Injected dependencies (loadState, log, metameDir)
402
+ * @returns {string} statePath - Absolute path to the written file
403
+ */
404
+ function generateStateFile(projectKey, config, deps) {
405
+ const metameDir = deps.metameDir || path.join(os.homedir(), '.metame');
406
+ const statePath = path.join(metameDir, 'memory', 'now', projectKey + '.md');
407
+
408
+ const { phase, mission, history } = replayEventLog(projectKey, deps);
409
+
410
+ const rs = deps.loadState().reactive?.[projectKey] || {};
411
+ const projectName = config.projects?.[projectKey]?.name || projectKey;
412
+
413
+ const round = Math.max(1, history.filter(h => h.phase === 'topic').length);
414
+
415
+ const lines = [
416
+ `# ${projectName} status`,
417
+ `project: "${mission?.title || 'unknown'}"`,
418
+ `phase: ${phase || 'topic'}`,
419
+ `status: ${rs.status || 'idle'}`,
420
+ 'waiting_for: ""',
421
+ `round: ${round}`,
422
+ `last_update: "${new Date().toISOString()}"`,
423
+ '',
424
+ '# Phase history (from event log)',
425
+ ];
426
+
427
+ for (const h of history) {
428
+ lines.push(` - phase: ${h.phase}`);
429
+ lines.push(` date: "${h.date}"`);
430
+ }
431
+
432
+ fs.mkdirSync(path.dirname(statePath), { recursive: true });
433
+ fs.writeFileSync(statePath, lines.join('\n'), 'utf8');
434
+
435
+ return statePath;
436
+ }
437
+
438
+ /**
439
+ * Periodic reconciliation check for all perpetual projects.
440
+ * Zero-token: pure state file inspection, no LLM calls.
441
+ */
442
+ function reconcilePerpetualProjects(config, deps) {
443
+ const projects = config.projects || {};
444
+ for (const [key, proj] of Object.entries(projects)) {
445
+ if (!proj.reactive) continue;
446
+
447
+ const st = deps.loadState();
448
+ const rs = st.reactive?.[key];
449
+ if (!rs || rs.status !== 'running') continue;
450
+
451
+ const lastUpdate = new Date(rs.updated_at).getTime();
452
+ if (!Number.isFinite(lastUpdate)) {
453
+ deps.log('WARN', `Reconcile: ${key} has invalid updated_at: ${rs.updated_at}`);
454
+ continue;
455
+ }
456
+ const staleMinutes = proj.stale_timeout_minutes || 120;
457
+ const staleThreshold = staleMinutes * 60 * 1000;
458
+
459
+ if (Date.now() - lastUpdate > staleThreshold) {
460
+ deps.log('WARN', `Reconcile: ${key} stuck since ${rs.updated_at}`);
461
+ setReactiveStatus(st, key, 'stale', 'no_activity');
462
+ deps.saveState(st);
463
+ appendEvent(key, { type: 'STALE', last_signal: rs.last_signal || '' }, deps.metameDir);
464
+ if (deps.notifyUser) {
465
+ const pName = proj.name || key;
466
+ deps.notifyUser(`⚠️ ${pName} stale: no activity for ${staleMinutes}+ minutes (last signal: ${rs.last_signal || 'none'})`);
467
+ }
468
+ }
469
+ }
470
+ }
471
+
472
+ // ── Memory System (L1/L2) ───────────────────────────────────────
473
+
474
+ /**
475
+ * Parse event log file into an array of event objects.
476
+ * Single read — callers share the result to avoid redundant I/O.
477
+ *
478
+ * @param {string} projectKey
479
+ * @param {object} deps
480
+ * @returns {Array<object>} Parsed events (malformed lines silently skipped)
481
+ */
482
+ function parseEventLog(projectKey, deps) {
483
+ const metameDir = deps.metameDir || path.join(os.homedir(), '.metame');
484
+ const evDir = path.join(metameDir, 'events');
485
+ const logPath = path.join(evDir, `${projectKey}.jsonl`);
486
+ if (!fs.existsSync(logPath)) return [];
487
+
488
+ const raw = fs.readFileSync(logPath, 'utf8').trim().split('\n').filter(Boolean);
489
+ const events = [];
490
+ for (const line of raw) {
491
+ try { events.push(JSON.parse(line)); } catch { /* skip malformed */ }
492
+ }
493
+ return events;
494
+ }
495
+
496
+ /**
497
+ * Build L1 running memory from parsed events.
498
+ * Extracts key decisions, lessons, phase trail, and round count
499
+ * from the current mission (since last MISSION_START).
500
+ *
501
+ * @param {string} projectKey
502
+ * @param {object} config
503
+ * @param {object} deps
504
+ * @param {Array<object>} [parsedEvents] - Pre-parsed events (avoids re-read)
505
+ * @returns {string} Markdown string (~600-800 tokens)
506
+ */
507
+ function buildRunningMemory(projectKey, config, deps, parsedEvents) {
508
+ const events = parsedEvents || parseEventLog(projectKey, deps);
509
+ if (events.length === 0) return '';
510
+
511
+ // Find last MISSION_START to scope to current mission
512
+ let missionStartIdx = 0;
513
+ for (let i = events.length - 1; i >= 0; i--) {
514
+ if (events[i].type === 'MISSION_START') {
515
+ missionStartIdx = i;
516
+ break;
517
+ }
518
+ }
519
+
520
+ const decisions = [];
521
+ const lessons = [];
522
+ const phaseTrail = [];
523
+ let roundCount = 0;
524
+
525
+ const decisionVerbs = /(?:chose|decided|switched|because|instead|using|adopted|rejected)/i;
526
+
527
+ for (let i = missionStartIdx; i < events.length; i++) {
528
+ const evt = events[i];
529
+
530
+ if (evt.type === 'MEMBER_COMPLETE') roundCount++;
531
+
532
+ if (evt.type === 'DISPATCH' && evt.prompt && evt.prompt.length > 80 && decisionVerbs.test(evt.prompt)) {
533
+ decisions.push({ round: roundCount, text: evt.prompt.slice(0, 150) });
534
+ }
535
+
536
+ if (evt.type === 'PHASE_GATE' && !evt.passed && evt.details) {
537
+ lessons.push({ round: roundCount, text: evt.details.slice(0, 120) });
538
+ }
539
+
540
+ if (evt.type === 'PHASE_GATE' && evt.passed) {
541
+ phaseTrail.push({ phase: evt.phase, round: roundCount });
542
+ }
543
+ }
544
+
545
+ const recentDecisions = decisions.slice(-5);
546
+ const recentLessons = lessons.slice(-5);
547
+
548
+ const parts = [];
549
+
550
+ if (recentDecisions.length > 0) {
551
+ if (parts.length > 0) parts.push('');
552
+ parts.push('## Recent Decisions');
553
+ for (const d of recentDecisions) {
554
+ parts.push(`- [R${d.round}] ${d.text}`);
555
+ }
556
+ }
557
+
558
+ if (recentLessons.length > 0) {
559
+ if (parts.length > 0) parts.push('');
560
+ parts.push('## Lessons Learned');
561
+ for (const l of recentLessons) {
562
+ parts.push(`- [R${l.round}] ${l.text}`);
563
+ }
564
+ }
565
+
566
+ if (phaseTrail.length > 0) {
567
+ if (parts.length > 0) parts.push('');
568
+ parts.push('## Phase Trail');
569
+ parts.push(phaseTrail.map(p => `${p.phase}(R${p.round})`).join(' → '));
570
+ }
571
+
572
+ if (parts.length === 0) return '';
573
+ return parts.join('\n');
574
+ }
575
+
576
+ /**
577
+ * Scan workspace for relevant artifacts (files sorted by mtime).
578
+ *
579
+ * @param {string} projectKey
580
+ * @param {object} config
581
+ * @param {object} deps
582
+ * @returns {Array<{ path: string, desc: string }>} Top 5 artifacts
583
+ */
584
+ function scanRelevantArtifacts(projectKey, config, deps) {
585
+ const projectCwd = resolveProjectCwd(projectKey, config);
586
+ if (!projectCwd) return [];
587
+
588
+ const wsDir = path.join(projectCwd, 'workspace');
589
+ if (!fs.existsSync(wsDir)) return [];
590
+
591
+ const validExts = new Set(['.md', '.json', '.tsv', '.py', '.csv']);
592
+ const files = [];
593
+
594
+ // Walk max depth 2
595
+ try {
596
+ const d1Entries = fs.readdirSync(wsDir, { withFileTypes: true });
597
+ for (const e1 of d1Entries) {
598
+ const p1 = path.join(wsDir, e1.name);
599
+ if (e1.isFile() && validExts.has(path.extname(e1.name))) {
600
+ try { files.push({ abs: p1, rel: `workspace/${e1.name}`, mtime: fs.statSync(p1).mtimeMs }); } catch { /* skip */ }
601
+ } else if (e1.isDirectory()) {
602
+ try {
603
+ const d2Entries = fs.readdirSync(p1, { withFileTypes: true });
604
+ for (const e2 of d2Entries) {
605
+ if (e2.isFile() && validExts.has(path.extname(e2.name))) {
606
+ const p2 = path.join(p1, e2.name);
607
+ try { files.push({ abs: p2, rel: `workspace/${e1.name}/${e2.name}`, mtime: fs.statSync(p2).mtimeMs }); } catch { /* skip */ }
608
+ }
609
+ }
610
+ } catch { /* skip unreadable dirs */ }
611
+ }
612
+ }
613
+ } catch { return []; }
614
+
615
+ // Sort by mtime descending, take top 5
616
+ files.sort((a, b) => b.mtime - a.mtime);
617
+ const top = files.slice(0, 5);
618
+
619
+ // Heuristic descriptions based on path/name
620
+ const descMap = {
621
+ 'progress.tsv': 'phase progress tracker',
622
+ 'results': 'experiment results',
623
+ 'proposal': 'research proposal',
624
+ 'draft': 'paper draft',
625
+ 'notes': 'research notes',
626
+ 'config': 'configuration',
627
+ 'data': 'dataset',
628
+ };
629
+
630
+ return top.map(f => {
631
+ let desc = path.extname(f.rel).slice(1) + ' file';
632
+ for (const [key, label] of Object.entries(descMap)) {
633
+ if (f.rel.toLowerCase().includes(key)) { desc = label; break; }
634
+ }
635
+ return { path: f.rel, desc };
636
+ });
637
+ }
638
+
639
+ /**
640
+ * Build L2 working memory from event log replay + memory.db FTS5.
641
+ *
642
+ * @param {string} projectKey
643
+ * @param {object} config
644
+ * @param {object} deps
645
+ * @returns {string} Markdown string (~300-500 tokens)
646
+ */
647
+ function buildWorkingMemory(projectKey, config, deps) {
648
+ const parts = [];
649
+
650
+ // Phase history as causal chain from event replay
651
+ const { phase, mission, history } = replayEventLog(projectKey, deps);
652
+
653
+ // FTS5 query: mission title + current phase (fixed rule, no smart inference)
654
+ const query = ((mission?.title || '') + ' ' + (phase || '')).trim();
655
+ if (!query) return '';
656
+
657
+ let facts = [];
658
+ try {
659
+ const memory = require('./memory');
660
+ memory.acquire();
661
+ try {
662
+ facts = memory.searchFacts(query, { limit: 5, project: projectKey });
663
+ } finally {
664
+ memory.release();
665
+ }
666
+ } catch { /* memory.db unavailable — graceful degradation */ }
667
+
668
+ if (facts.length > 0) {
669
+ parts.push('## Long-term Context');
670
+ for (const f of facts) {
671
+ const tag = f.relation || f.entity || 'fact';
672
+ parts.push(`- [${tag}] ${f.value}`);
673
+ }
674
+ }
675
+
676
+ if (parts.length === 0) return '';
677
+ return parts.join('\n');
678
+ }
679
+
680
+ /**
681
+ * Persist unified memory file (L1 + L2 merged).
682
+ * L1 rebuilds every round; L2 refreshes every 5 rounds or on phase change.
683
+ *
684
+ * @param {string} projectKey
685
+ * @param {object} config
686
+ * @param {object} deps
687
+ * @param {object} [opts]
688
+ * @param {boolean} [opts.phaseChanged]
689
+ */
690
+ function persistMemoryFiles(projectKey, config, deps, opts = {}) {
691
+ 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`);
695
+
696
+ // Single parse of event log — shared across L1 and round counting
697
+ const events = parseEventLog(projectKey, deps);
698
+
699
+ // Derive round count and mission title from parsed events
700
+ let roundCount = 0;
701
+ let missionTitle = 'unknown';
702
+ let maxDepth = 50;
703
+ for (const evt of events) {
704
+ if (evt.type === 'MEMBER_COMPLETE') roundCount++;
705
+ if (evt.type === 'MISSION_START') { missionTitle = evt.mission_title || 'unknown'; roundCount = 0; }
706
+ if (evt.type === 'MISSION_COMPLETE') roundCount = 0;
707
+ }
708
+
709
+ // Read manifest for max_depth
710
+ const projectCwd = resolveProjectCwd(projectKey, config);
711
+ if (projectCwd) {
712
+ const manifest = loadProjectManifest(projectCwd);
713
+ if (manifest?.max_depth) maxDepth = manifest.max_depth;
714
+ }
715
+
716
+ // Always rebuild L1 (pass pre-parsed events to avoid re-read)
717
+ const l1 = buildRunningMemory(projectKey, config, deps, events);
718
+ const artifacts = scanRelevantArtifacts(projectKey, config, deps);
719
+
720
+ // Conditionally rebuild L2 (every 5 rounds or phase change)
721
+ const shouldRefreshL2 = opts.phaseChanged || (roundCount % 5 === 0);
722
+ let l2 = '';
723
+ if (shouldRefreshL2) {
724
+ l2 = buildWorkingMemory(projectKey, config, deps);
725
+ // Stash L2 for next time
726
+ try {
727
+ const l2CachePath = path.join(memDir, `${projectKey}_l2cache.md`);
728
+ fs.writeFileSync(l2CachePath, l2, 'utf8');
729
+ } catch { /* non-critical */ }
730
+ } else {
731
+ // Read stale L2 from cache
732
+ try {
733
+ const l2CachePath = path.join(memDir, `${projectKey}_l2cache.md`);
734
+ if (fs.existsSync(l2CachePath)) {
735
+ l2 = fs.readFileSync(l2CachePath, 'utf8').trim();
736
+ }
737
+ } catch { /* non-critical */ }
738
+ }
739
+
740
+ // Build merged document
741
+ const parts = [`# Memory Context: ${missionTitle} (round ${roundCount}/${maxDepth})`];
742
+
743
+ if (l1) parts.push('', l1);
744
+
745
+ if (artifacts.length > 0) {
746
+ parts.push('', '## Current Artifacts');
747
+ for (const a of artifacts) {
748
+ parts.push(`- ${a.path} — ${a.desc}`);
749
+ }
750
+ }
751
+
752
+ if (l2) parts.push('', l2);
753
+
754
+ const content = parts.join('\n') + '\n';
755
+ fs.writeFileSync(memPath, content, 'utf8');
756
+ return memPath;
757
+ }
758
+
759
+ /**
760
+ * Pattern-based inline fact extraction from agent output.
761
+ * Zero LLM, zero agent format dependency.
762
+ *
763
+ * @param {string} projectKey
764
+ * @param {string} memberOutput
765
+ * @param {string} [phase]
766
+ * @returns {Array<{ entity: string, relation: string, value: string, confidence: string }>}
767
+ */
768
+ function extractInlineFacts(projectKey, memberOutput, phase) {
769
+ if (!memberOutput || typeof memberOutput !== 'string') return [];
770
+
771
+ const facts = [];
772
+ const CAP = 3;
773
+
774
+ // Pattern 1: Error/OOM patterns → bug_lesson
775
+ const errorRe = /(?:OOM|out of memory|CUDA error|killed|Error:|Exception:|Failed:)\s*(.{15,150})/gi;
776
+ let match;
777
+ while ((match = errorRe.exec(memberOutput)) !== null && facts.length < CAP) {
778
+ facts.push({
779
+ entity: projectKey,
780
+ relation: 'bug_lesson',
781
+ value: match[0].trim().slice(0, 150),
782
+ confidence: 'medium',
783
+ });
784
+ }
785
+
786
+ // Pattern 2: Decision verbs → tech_decision
787
+ const decisionRe = /(?:decided|chose|selected|switched to|rejected|using|adopted)\s+(.{20,150})/gi;
788
+ while ((match = decisionRe.exec(memberOutput)) !== null && facts.length < CAP) {
789
+ facts.push({
790
+ entity: projectKey,
791
+ relation: 'tech_decision',
792
+ value: match[0].trim().slice(0, 150),
793
+ confidence: 'low',
794
+ });
795
+ }
796
+
797
+ return facts.slice(0, CAP);
798
+ }
799
+
800
+ /**
801
+ * Extract a high-density summary from agent output.
802
+ * Tail-biased: conclusions and results are usually at the end.
803
+ * Zero LLM — pure heuristic.
804
+ *
805
+ * Strategy:
806
+ * - Head (~200 chars): who's speaking, opening context
807
+ * - Key lines: lines containing signal words (conclusions, decisions, errors)
808
+ * - Tail (~600 chars): final output, conclusions, recommendations
809
+ *
810
+ * @param {string} output - Raw agent output
811
+ * @param {number} [maxLen=1200] - Max total length
812
+ * @returns {string}
813
+ */
814
+ function extractOutputSummary(output, maxLen = 1200) {
815
+ if (!output || output.length <= maxLen) return output || '';
816
+
817
+ // Adaptive head/tail sizes — scale down for small maxLen
818
+ const HEAD_LEN = Math.min(200, Math.floor(maxLen * 0.25));
819
+ const TAIL_LEN = Math.min(600, Math.floor(maxLen * 0.6));
820
+ const KEY_BUDGET = Math.max(0, maxLen - HEAD_LEN - TAIL_LEN - 40);
821
+
822
+ const head = output.slice(0, HEAD_LEN);
823
+ const tail = output.slice(-TAIL_LEN);
824
+
825
+ // Extract key signal lines from the middle (skip head/tail zones)
826
+ let keyLines = '';
827
+ if (KEY_BUDGET > 0 && output.length > HEAD_LEN + TAIL_LEN) {
828
+ const middleZone = output.slice(HEAD_LEN, -TAIL_LEN);
829
+ const signalRe = /(?:结论|conclusion|found that|result|决定|recommend|建议|发现|关键|key finding|error|OOM|failed|chose|decided|switched|important|注意|warning)/i;
830
+ keyLines = middleZone.split('\n')
831
+ .filter(line => line.trim().length > 15 && signalRe.test(line))
832
+ .slice(0, 5)
833
+ .join('\n')
834
+ .slice(0, KEY_BUDGET);
835
+ }
836
+
837
+ const parts = [head.trimEnd()];
838
+ if (keyLines) parts.push('[...key findings...]', keyLines);
839
+ else parts.push('[...]');
840
+ parts.push(tail.trimStart());
841
+
842
+ return parts.join('\n').slice(0, maxLen);
843
+ }
844
+
249
845
  // ── Main handler ────────────────────────────────────────────────
250
846
 
251
847
  /**
@@ -269,7 +865,17 @@ function runCompletionHooks(projectKey, projectCwd, deps) {
269
865
  function handleReactiveOutput(targetProject, output, config, deps) {
270
866
  if (!config || !config.projects) return;
271
867
 
272
- const signals = parseReactiveSignals(output);
868
+ // Scoped event logger — uses deps.metameDir for test isolation
869
+ const logEvent = (key, event) => appendEvent(key, event, deps.metameDir);
870
+
871
+ // Resolve manifest for completion signal
872
+ const projectCwd = isReactiveParent(targetProject, config)
873
+ ? resolveProjectCwd(targetProject, config)
874
+ : resolveProjectCwd(findReactiveParent(targetProject, config), config);
875
+ const manifest = projectCwd ? loadProjectManifest(projectCwd) : null;
876
+ const completionSignal = manifest?.completion_signal || 'MISSION_COMPLETE';
877
+
878
+ const signals = parseReactiveSignals(output, completionSignal);
273
879
  const hasSignals = signals.directives.length > 0 || signals.complete;
274
880
 
275
881
  // ── Case 1: targetProject is a reactive parent ──
@@ -277,39 +883,46 @@ function handleReactiveOutput(targetProject, output, config, deps) {
277
883
  if (!hasSignals) return;
278
884
 
279
885
  const projectKey = targetProject;
886
+ const pName = config.projects[projectKey]?.name || projectKey;
280
887
  const st = deps.loadState();
281
888
  const rs = getReactiveState(st, projectKey);
282
889
 
283
- // RESEARCH_COMPLETE takes priority
890
+ // Mission complete takes priority
284
891
  if (signals.complete) {
285
- deps.log('INFO', `Reactive: ${projectKey} research completed`);
892
+ deps.log('INFO', `Reactive: ${projectKey} mission completed`);
286
893
  setReactiveStatus(st, projectKey, 'completed', '');
287
894
  st.reactive[projectKey].depth = 0;
288
- rs.last_signal = 'RESEARCH_COMPLETE';
895
+ rs.last_signal = 'MISSION_COMPLETE';
289
896
  deps.saveState(st);
897
+ logEvent(projectKey, { type: 'MISSION_COMPLETE' });
290
898
 
291
- // Run completion hooks (archive + next topic) if project has scripts/
292
- const projectCwd = resolveProjectCwd(projectKey, config);
293
- if (projectCwd) {
294
- const completionResult = runCompletionHooks(projectKey, projectCwd, deps);
295
- const notifyMsg = completionResult.nextTopic
296
- ? `\u2705 科研课题已完成并归档。下一课题: ${completionResult.nextTopic}`
297
- : '\u2705 科研课题已完成并归档。无待处理课题,系统进入等待。';
899
+ // Run completion hooks (archive + next mission)
900
+ const pCwd = resolveProjectCwd(projectKey, config);
901
+ if (pCwd) {
902
+ const completionResult = runCompletionHooks(projectKey, pCwd, deps);
903
+ if (completionResult.archived) {
904
+ logEvent(projectKey, { type: 'ARCHIVE', path: pCwd });
905
+ }
906
+ const notifyMsg = completionResult.nextMission
907
+ ? `\u2705 ${pName} mission completed. Next: ${completionResult.nextMission}`
908
+ : `\u2705 ${pName} mission completed. No pending missions — entering idle.`;
298
909
  if (deps.notifyUser) deps.notifyUser(notifyMsg);
299
910
 
300
- // Auto-start next topic if available — requires budget to be OK
301
- if (completionResult.nextTopic && completionResult.nextTopicPrompt) {
911
+ // Auto-start next mission if available — requires budget to be OK
912
+ if (completionResult.nextMission && completionResult.nextMissionPrompt) {
302
913
  if (!deps.checkBudget(config, st)) {
303
- deps.log('WARN', `Reactive: budget exceeded, skipping auto-start of next topic for ${projectKey}`);
304
- if (deps.notifyUser) deps.notifyUser(`\u26a0\ufe0f 下一课题 "${completionResult.nextTopic}" 已就绪但预算不足,暂不启动`);
914
+ deps.log('WARN', `Reactive: budget exceeded, skipping auto-start for ${projectKey}`);
915
+ logEvent(projectKey, { type: 'BUDGET_LIMIT', action: 'skip_next_mission' });
916
+ if (deps.notifyUser) deps.notifyUser(`\u26a0\ufe0f Next mission "${completionResult.nextMission}" ready but budget exceeded`);
305
917
  } else {
306
- deps.log('INFO', `Reactive: auto-starting next topic for ${projectKey}: ${completionResult.nextTopic}`);
918
+ deps.log('INFO', `Reactive: auto-starting next mission for ${projectKey}: ${completionResult.nextMission}`);
919
+ logEvent(projectKey, { type: 'MISSION_START', mission_id: completionResult.nextMissionId || '', mission_title: completionResult.nextMission });
307
920
  setReactiveStatus(st, projectKey, 'running', '');
308
921
  st.reactive[projectKey].depth = 0;
309
922
  deps.saveState(st);
310
923
  deps.handleDispatchItem({
311
924
  target: projectKey,
312
- prompt: completionResult.nextTopicPrompt,
925
+ prompt: completionResult.nextMissionPrompt,
313
926
  from: '_system',
314
927
  _reactive: true,
315
928
  new_session: true,
@@ -317,7 +930,7 @@ function handleReactiveOutput(targetProject, output, config, deps) {
317
930
  }
318
931
  }
319
932
  } else {
320
- if (deps.notifyUser) deps.notifyUser('\u2705 科研课题已完成');
933
+ if (deps.notifyUser) deps.notifyUser(`\u2705 ${pName} mission completed`);
321
934
  }
322
935
  return;
323
936
  }
@@ -330,17 +943,19 @@ function handleReactiveOutput(targetProject, output, config, deps) {
330
943
  deps.log('WARN', `Reactive: budget exceeded, pausing ${projectKey}`);
331
944
  setReactiveStatus(st, projectKey, 'paused', 'budget_exceeded');
332
945
  deps.saveState(st);
333
- if (deps.notifyUser) deps.notifyUser('\u26a0\ufe0f \u79d1\u7814\u5faa\u73af\u5df2\u6682\u505c\uff1a\u4eca\u65e5\u9884\u7b97\u5df2\u8017\u5c3d');
946
+ logEvent(projectKey, { type: 'BUDGET_LIMIT', action: 'paused' });
947
+ if (deps.notifyUser) deps.notifyUser(`\u26a0\ufe0f ${pName} paused: daily budget exceeded`);
334
948
  return;
335
949
  }
336
950
 
337
- // Depth gate
338
- const maxDepth = rs.max_depth || 50;
951
+ // Depth gate (manifest max_depth overrides default)
952
+ const maxDepth = manifest?.max_depth || rs.max_depth || 50;
339
953
  if (rs.depth >= maxDepth) {
340
954
  deps.log('WARN', `Reactive: depth ${rs.depth} >= ${maxDepth}, pausing ${projectKey}`);
341
955
  setReactiveStatus(st, projectKey, 'paused', 'depth_exceeded');
342
956
  deps.saveState(st);
343
- if (deps.notifyUser) deps.notifyUser(`\u26a0\ufe0f \u79d1\u7814\u5faa\u73af\u5df2\u6682\u505c\uff1a\u5faa\u73af\u6df1\u5ea6\u8fbe\u5230\u4e0a\u9650 ${maxDepth}`);
957
+ logEvent(projectKey, { type: 'DEPTH_LIMIT', depth: rs.depth, action: 'paused' });
958
+ if (deps.notifyUser) deps.notifyUser(`\u26a0\ufe0f ${pName} paused: depth limit ${maxDepth} reached`);
344
959
  return;
345
960
  }
346
961
 
@@ -351,6 +966,7 @@ function handleReactiveOutput(targetProject, output, config, deps) {
351
966
 
352
967
  // Dispatch each directive with fresh session
353
968
  for (const d of signals.directives) {
969
+ logEvent(projectKey, { type: 'DISPATCH', target: d.target, prompt: d.prompt.slice(0, 200) });
354
970
  deps.handleDispatchItem({
355
971
  target: d.target,
356
972
  prompt: d.prompt,
@@ -359,6 +975,10 @@ function handleReactiveOutput(targetProject, output, config, deps) {
359
975
  new_session: true,
360
976
  }, config);
361
977
  }
978
+
979
+ // Point B: Persist memory after parent dispatches
980
+ try { persistMemoryFiles(projectKey, config, deps); } catch { /* non-critical */ }
981
+
362
982
  return;
363
983
  }
364
984
 
@@ -366,6 +986,7 @@ function handleReactiveOutput(targetProject, output, config, deps) {
366
986
  const parentKey = findReactiveParent(targetProject, config);
367
987
  if (!parentKey || !isReactiveParent(parentKey, config)) return;
368
988
 
989
+ const pName = config.projects[parentKey]?.name || parentKey;
369
990
  const st = deps.loadState();
370
991
 
371
992
  // Budget gate
@@ -373,18 +994,21 @@ function handleReactiveOutput(targetProject, output, config, deps) {
373
994
  deps.log('WARN', `Reactive: budget exceeded, pausing ${parentKey} (via member ${targetProject})`);
374
995
  setReactiveStatus(st, parentKey, 'paused', 'budget_exceeded');
375
996
  deps.saveState(st);
376
- if (deps.notifyUser) deps.notifyUser('\u26a0\ufe0f \u79d1\u7814\u5faa\u73af\u5df2\u6682\u505c\uff1a\u4eca\u65e5\u9884\u7b97\u5df2\u8017\u5c3d');
997
+ logEvent(parentKey, { type: 'BUDGET_LIMIT', action: 'paused', trigger: targetProject });
998
+ if (deps.notifyUser) deps.notifyUser(`\u26a0\ufe0f ${pName} paused: daily budget exceeded`);
377
999
  return;
378
1000
  }
379
1001
 
380
- // Depth gate
1002
+ // Depth gate (manifest max_depth overrides default)
381
1003
  const rs = getReactiveState(st, parentKey);
382
- const maxDepth = rs.max_depth || 50;
1004
+ const parentManifestForDepth = projectCwd ? loadProjectManifest(projectCwd) : null;
1005
+ const maxDepth = parentManifestForDepth?.max_depth || rs.max_depth || 50;
383
1006
  if (rs.depth >= maxDepth) {
384
1007
  deps.log('WARN', `Reactive: depth ${rs.depth} >= ${maxDepth}, pausing ${parentKey} (via member ${targetProject})`);
385
1008
  setReactiveStatus(st, parentKey, 'paused', 'depth_exceeded');
386
1009
  deps.saveState(st);
387
- if (deps.notifyUser) deps.notifyUser(`\u26a0\ufe0f \u79d1\u7814\u5faa\u73af\u5df2\u6682\u505c\uff1a\u5faa\u73af\u6df1\u5ea6\u8fbe\u5230\u4e0a\u9650 ${maxDepth}`);
1010
+ logEvent(parentKey, { type: 'DEPTH_LIMIT', depth: rs.depth, action: 'paused' });
1011
+ if (deps.notifyUser) deps.notifyUser(`\u26a0\ufe0f ${pName} paused: depth limit ${maxDepth} reached`);
388
1012
  return;
389
1013
  }
390
1014
 
@@ -393,21 +1017,76 @@ function handleReactiveOutput(targetProject, output, config, deps) {
393
1017
  rs.last_signal = 'MEMBER_COMPLETE';
394
1018
  rs.updated_at = new Date().toISOString();
395
1019
  deps.saveState(st);
1020
+ logEvent(parentKey, { type: 'MEMBER_COMPLETE', member: targetProject, summary_length: output.length });
396
1021
 
397
1022
  // Run verifier if available
398
1023
  const verifyResult = deps.runVerifier
399
1024
  ? deps.runVerifier(parentKey, config)
400
1025
  : runProjectVerifier(parentKey, config, deps);
401
1026
 
1027
+ // Log verifier result as event
1028
+ if (verifyResult) {
1029
+ logEvent(parentKey, {
1030
+ type: 'PHASE_GATE',
1031
+ phase: verifyResult.phase || '',
1032
+ passed: !!verifyResult.passed,
1033
+ details: (verifyResult.details || '').slice(0, 500),
1034
+ artifacts: verifyResult.artifacts || [],
1035
+ });
1036
+ }
1037
+
1038
+ // DESIGN CONTRACT: Error Semantic Isolation
1039
+ // If verifier reports infrastructure failure (_infraFailure),
1040
+ // pause the project and notify user — do NOT blame the agent.
1041
+ if (verifyResult?._infraFailure) {
1042
+ deps.log('WARN', `Verifier infra failure for ${parentKey}: ${verifyResult.details}`);
1043
+ setReactiveStatus(st, parentKey, 'paused', 'infra_failure');
1044
+ deps.saveState(st);
1045
+ logEvent(parentKey, { type: 'INFRA_PAUSE', details: verifyResult.details });
1046
+ if (deps.notifyUser) deps.notifyUser(`\u26a0\ufe0f ${pName} paused: external API unavailable. Not an agent error.`);
1047
+ return; // Do NOT wake agent
1048
+ }
1049
+
1050
+ // Update progress.tsv projection
1051
+ const parentCwd = resolveProjectCwd(parentKey, config);
1052
+ if (parentCwd) {
1053
+ try { projectProgressTsv(parentCwd, parentKey, deps.metameDir); } catch { /* non-critical */ }
1054
+ }
1055
+
402
1056
  const verifierBlock = verifyResult
403
- ? `\n\n[验证门结果] phase=${verifyResult.phase} passed=${verifyResult.passed}\n${verifyResult.details}${verifyResult.hints?.length ? '\n建议: ' + verifyResult.hints.join('; ') : ''}`
404
- : '\n\n[验证门结果] passed=false\n验证器未配置或不可用,请谨慎推进阶段';
1057
+ ? `\n\n[Verifier] phase=${verifyResult.phase} passed=${verifyResult.passed}\n${verifyResult.details}${verifyResult.hints?.length ? '\nHints: ' + verifyResult.hints.join('; ') : ''}`
1058
+ : '\n\n[Verifier] not configured — proceed with caution';
1059
+
1060
+ // Generate state file from event log BEFORE waking parent (event sourcing projection)
1061
+ if (parentCwd) {
1062
+ try { generateStateFile(parentKey, config, deps); } catch { /* non-critical */ }
1063
+ }
1064
+
1065
+ // Point A: Persist memory + extract inline facts after verifier, before waking parent
1066
+ const phaseChanged = verifyResult?.passed && !!verifyResult?.phase;
1067
+ try { persistMemoryFiles(parentKey, config, deps, { phaseChanged }); } catch { /* non-critical */ }
1068
+
1069
+ // Inline fact extraction from member output
1070
+ try {
1071
+ const inlineFacts = extractInlineFacts(parentKey, output, verifyResult?.phase);
1072
+ if (inlineFacts.length > 0) {
1073
+ const memory = require('./memory');
1074
+ memory.acquire();
1075
+ try {
1076
+ memory.saveFacts(`reactive-${parentKey}-${Date.now()}`, parentKey, inlineFacts);
1077
+ } finally {
1078
+ memory.release();
1079
+ }
1080
+ }
1081
+ } catch { /* non-critical */ }
405
1082
 
406
- // Trigger parent with member's output summary
407
- const summary = output.slice(0, 1200);
1083
+ // Trigger parent with member's output summary (tail-biased extraction)
1084
+ const parentManifest = parentCwd ? loadProjectManifest(parentCwd) : null;
1085
+ const signal = parentManifest?.completion_signal || 'MISSION_COMPLETE';
1086
+ const summary = extractOutputSummary(output);
408
1087
  deps.handleDispatchItem({
409
1088
  target: parentKey,
410
- prompt: `[团队成员交付] ${targetProject} 完成任务。\n\n产出摘要:\n${summary}${verifierBlock}\n\n请阅读产出,评估质量,更新 now/${parentKey}.md,然后决定下一步。\n如需派发新任务,在回复末尾使用 NEXT_DISPATCH 指令。如研究全部完成,输出 RESEARCH_COMPLETE。`,
1089
+ prompt: `[${targetProject} delivery]${verifierBlock}\n\n${summary}\n\nDecide next step. Use NEXT_DISPATCH or ${signal}.`,
411
1090
  from: targetProject,
412
1091
  _reactive: true,
413
1092
  new_session: true,
@@ -417,5 +1096,7 @@ function handleReactiveOutput(targetProject, output, config, deps) {
417
1096
  module.exports = {
418
1097
  handleReactiveOutput,
419
1098
  parseReactiveSignals,
420
- __test: { runProjectVerifier, readPhaseFromState, resolveProjectCwd },
1099
+ reconcilePerpetualProjects,
1100
+ replayEventLog,
1101
+ __test: { runProjectVerifier, readPhaseFromState, resolveProjectCwd, appendEvent, projectProgressTsv, generateStateFile, loadProjectManifest, resolveProjectScripts, parseEventLog, buildRunningMemory, scanRelevantArtifacts, buildWorkingMemory, persistMemoryFiles, extractInlineFacts, extractOutputSummary },
421
1102
  };