metame-cli 1.5.10 → 1.5.11

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.
@@ -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,19 @@ 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
186
  const statePath = path.join(
138
187
  deps.metameDir || path.join(os.homedir(), '.metame'),
139
188
  'memory', 'now', `${projectKey}.md`
140
189
  );
141
190
  const phase = readPhaseFromState(statePath);
191
+ const relVerifier = path.relative(projectCwd, scripts.verifier);
142
192
 
143
193
  try {
144
- const output = execSync('node scripts/research-verifier.js', {
194
+ const output = execSync(`node "${relVerifier}"`, {
145
195
  cwd: projectCwd,
146
196
  encoding: 'utf8',
147
197
  timeout: 15000,
@@ -155,7 +205,7 @@ function runProjectVerifier(projectKey, config, deps) {
155
205
  return JSON.parse(output);
156
206
  } catch (e) {
157
207
  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 脚本'] };
208
+ return { passed: false, phase: phase || 'unknown', details: `verifier_error: ${e.message.slice(0, 200)}`, artifacts: [], hints: ['Verifier script failed — check scripts/'] };
159
209
  }
160
210
  }
161
211
 
@@ -165,13 +215,13 @@ function runProjectVerifier(projectKey, config, deps) {
165
215
  * @returns {{ archived: boolean, nextTopic: string|null, nextTopicPrompt: string|null }}
166
216
  */
167
217
  function runCompletionHooks(projectKey, projectCwd, deps) {
168
- const result = { archived: false, nextTopic: null, nextTopicPrompt: null };
218
+ const manifest = loadProjectManifest(projectCwd);
219
+ const scripts = resolveProjectScripts(projectCwd, manifest);
220
+ const result = { archived: false, nextMission: null, nextMissionId: null, nextMissionPrompt: null };
169
221
 
170
- // 1. Archive
171
- const archiveScript = path.join(projectCwd, 'scripts', 'research-archive.js');
172
- if (fs.existsSync(archiveScript)) {
222
+ // 1. Archive (if script exists)
223
+ if (fs.existsSync(scripts.archiver)) {
173
224
  try {
174
- // Read project name from state file
175
225
  const statePath = path.join(
176
226
  deps.metameDir || path.join(os.homedir(), '.metame'),
177
227
  'memory', 'now', `${projectKey}.md`
@@ -183,7 +233,8 @@ function runCompletionHooks(projectKey, projectCwd, deps) {
183
233
  if (m) projectName = m[1];
184
234
  } catch { /* use projectKey */ }
185
235
 
186
- const archiveOut = execSync('node scripts/research-archive.js', {
236
+ const relArchiver = path.relative(projectCwd, scripts.archiver);
237
+ const archiveOut = execSync(`node "${relArchiver}"`, {
187
238
  cwd: projectCwd, encoding: 'utf8', timeout: 30000,
188
239
  env: { ...process.env, ARCHIVE_CWD: projectCwd, ARCHIVE_PROJECT_NAME: projectName, ARCHIVE_STATE_PATH: statePath },
189
240
  }).trim();
@@ -195,57 +246,228 @@ function runCompletionHooks(projectKey, projectCwd, deps) {
195
246
  }
196
247
  }
197
248
 
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
249
+ // 2. Mission queue — only proceed if archive succeeded
250
+ if (result.archived && fs.existsSync(scripts.missionQueue)) {
251
+ const relQueue = path.relative(projectCwd, scripts.missionQueue);
252
+ const queueEnv = { ...process.env, MISSION_CWD: projectCwd, TOPICS_CWD: projectCwd };
253
+ // Sanitize topic IDs to prevent shell injection (only allow alphanumeric, dash, underscore)
254
+ const sanitizeId = (id) => String(id || '').replace(/[^a-zA-Z0-9_-]/g, '');
255
+ // 2a. Complete current active mission
203
256
  try {
204
- const listOut = execSync('node scripts/topic-pool.js list', {
205
- cwd: projectCwd, encoding: 'utf8', timeout: 10000, env: topicEnv,
257
+ const listOut = execSync(`node "${relQueue}" list`, {
258
+ cwd: projectCwd, encoding: 'utf8', timeout: 10000, env: queueEnv,
206
259
  }).trim();
207
260
  const listResult = JSON.parse(listOut);
208
261
  if (listResult.success && Array.isArray(listResult.topics)) {
209
262
  const activeTopic = listResult.topics.find(t => t.status === 'active');
210
263
  if (activeTopic) {
211
- execSync(`node scripts/topic-pool.js complete ${activeTopic.id}`, {
212
- cwd: projectCwd, encoding: 'utf8', timeout: 10000, env: topicEnv,
264
+ execSync(`node "${relQueue}" complete ${sanitizeId(activeTopic.id)}`, {
265
+ cwd: projectCwd, encoding: 'utf8', timeout: 10000, env: queueEnv,
213
266
  });
214
- deps.log('INFO', `Reactive: completed active topic ${activeTopic.id}: ${activeTopic.title}`);
267
+ deps.log('INFO', `Reactive: completed mission ${activeTopic.id}: ${activeTopic.title}`);
215
268
  }
216
269
  }
217
270
  } catch (e) {
218
- deps.log('WARN', `Reactive: topic complete failed: ${e.message}`);
271
+ deps.log('WARN', `Reactive: mission complete failed: ${e.message}`);
219
272
  }
220
- // 2b. Get next pending topic
273
+ // 2b. Get next pending mission
221
274
  try {
222
- const nextOut = execSync('node scripts/topic-pool.js next', {
223
- cwd: projectCwd, encoding: 'utf8', timeout: 10000, env: topicEnv,
275
+ const nextOut = execSync(`node "${relQueue}" next`, {
276
+ cwd: projectCwd, encoding: 'utf8', timeout: 10000, env: queueEnv,
224
277
  }).trim();
225
278
  const nextResult = JSON.parse(nextOut);
226
279
  if (nextResult.success && nextResult.topic) {
227
- // Activate the next topic
228
280
  try {
229
- execSync(`node scripts/topic-pool.js activate ${nextResult.topic.id}`, {
230
- cwd: projectCwd, encoding: 'utf8', timeout: 10000, env: topicEnv,
281
+ execSync(`node "${relQueue}" activate ${sanitizeId(nextResult.topic.id)}`, {
282
+ cwd: projectCwd, encoding: 'utf8', timeout: 10000, env: queueEnv,
231
283
  });
232
284
  } catch (e) {
233
- deps.log('WARN', `Reactive: topic activate failed: ${e.message}`);
285
+ deps.log('WARN', `Reactive: mission activate failed: ${e.message}`);
234
286
  }
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}`);
287
+ result.nextMission = nextResult.topic.title;
288
+ result.nextMissionId = nextResult.topic.id || '';
289
+ 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.`;
290
+ deps.log('INFO', `Reactive: next mission for ${projectKey}: ${nextResult.topic.title}`);
238
291
  }
239
292
  } catch (e) {
240
- deps.log('WARN', `Reactive: topic pool query failed for ${projectKey}: ${e.message}`);
293
+ deps.log('WARN', `Reactive: mission queue query failed for ${projectKey}: ${e.message}`);
241
294
  }
242
- } else if (!result.archived && fs.existsSync(topicScript)) {
243
- deps.log('WARN', `Reactive: skipping topic pool for ${projectKey} — archive did not succeed`);
295
+ } else if (!result.archived && fs.existsSync(scripts.missionQueue)) {
296
+ deps.log('WARN', `Reactive: skipping mission queue for ${projectKey} — archive did not succeed`);
244
297
  }
245
298
 
246
299
  return result;
247
300
  }
248
301
 
302
+ // ── Event Log (Event Sourcing) ──────────────────────────────────
303
+
304
+ /**
305
+ * Append an event to the project's event log.
306
+ * Daemon-exclusive: agents cannot write to ~/.metame/events/.
307
+ */
308
+ function appendEvent(projectKey, event, metameDir) {
309
+ const evDir = metameDir ? path.join(metameDir, 'events') : EVENTS_DIR;
310
+ fs.mkdirSync(evDir, { recursive: true });
311
+ const logPath = path.join(evDir, `${projectKey}.jsonl`);
312
+ const line = JSON.stringify({ ts: new Date().toISOString(), ...event }) + '\n';
313
+ fs.appendFileSync(logPath, line, 'utf8');
314
+ }
315
+
316
+ /**
317
+ * Replay event log to derive current state.
318
+ * Returns { phase, mission, history[] }
319
+ *
320
+ * DESIGN CONTRACT (Tolerant Reader):
321
+ * Malformed lines (e.g. from crash/truncation) are skipped with a WARN log.
322
+ * This function NEVER throws.
323
+ */
324
+ function replayEventLog(projectKey, deps) {
325
+ const evDir = deps?.metameDir ? path.join(deps.metameDir, 'events') : EVENTS_DIR;
326
+ const logPath = path.join(evDir, `${projectKey}.jsonl`);
327
+ if (!fs.existsSync(logPath)) return { phase: '', mission: null, history: [] };
328
+
329
+ const lines = fs.readFileSync(logPath, 'utf8').trim().split('\n').filter(Boolean);
330
+ let phase = '';
331
+ let mission = null;
332
+ const history = [];
333
+
334
+ for (let i = 0; i < lines.length; i++) {
335
+ try {
336
+ const evt = JSON.parse(lines[i]);
337
+ if (evt.type === 'MISSION_START') {
338
+ mission = { id: evt.mission_id, title: evt.mission_title };
339
+ }
340
+ if (evt.type === 'PHASE_GATE' && evt.passed) {
341
+ phase = evt.phase;
342
+ history.push({ phase: evt.phase, date: evt.ts, artifacts: evt.artifacts });
343
+ }
344
+ if (evt.type === 'MISSION_COMPLETE') {
345
+ phase = '';
346
+ mission = null;
347
+ }
348
+ } catch {
349
+ // DESIGN CONTRACT: Tolerant Reader (尾行容错)
350
+ // 断电/Kernel Panic 可能导致最后一行残缺。
351
+ // 逐行 parse,损坏行静默丢弃 + log WARN,绝不 crash loop。
352
+ deps?.log?.('WARN', `Event log ${projectKey} line ${i + 1}: malformed JSON, skipped`);
353
+ }
354
+ }
355
+
356
+ return { phase, mission, history };
357
+ }
358
+
359
+ /**
360
+ * Generate progress.tsv as a human-readable projection of the event log.
361
+ * Not SoT — can be safely regenerated at any time.
362
+ */
363
+ function projectProgressTsv(projectCwd, projectKey, metameDir) {
364
+ const tsvPath = path.join(projectCwd, 'workspace', 'progress.tsv');
365
+ const header = 'phase\tresult\tverifier_passed\tartifact\ttimestamp\tnotes\n';
366
+
367
+ const evDir = metameDir ? path.join(metameDir, 'events') : EVENTS_DIR;
368
+ const logPath = path.join(evDir, `${projectKey}.jsonl`);
369
+ if (!fs.existsSync(logPath)) return;
370
+
371
+ const lines = fs.readFileSync(logPath, 'utf8').trim().split('\n').filter(Boolean);
372
+ let rows = header;
373
+ for (const line of lines) {
374
+ try {
375
+ const evt = JSON.parse(line);
376
+ if (evt.type === 'PHASE_GATE') {
377
+ rows += [
378
+ evt.phase,
379
+ evt.passed ? 'done' : 'in_progress',
380
+ String(evt.passed),
381
+ (evt.artifacts || [])[0] || '',
382
+ evt.ts,
383
+ (evt.details || '').replace(/[\t\n]/g, ' '),
384
+ ].join('\t') + '\n';
385
+ }
386
+ } catch { /* skip */ }
387
+ }
388
+
389
+ fs.mkdirSync(path.dirname(tsvPath), { recursive: true });
390
+ fs.writeFileSync(tsvPath, rows, 'utf8');
391
+ }
392
+
393
+ /**
394
+ * Generate now/<key>.md state file by replaying the event log.
395
+ * This is the canonical way to (re)build the agent-visible state file
396
+ * from the append-only event log (event sourcing projection).
397
+ *
398
+ * @param {string} projectKey
399
+ * @param {object} config - Full daemon config
400
+ * @param {object} deps - Injected dependencies (loadState, log, metameDir)
401
+ * @returns {string} statePath - Absolute path to the written file
402
+ */
403
+ function generateStateFile(projectKey, config, deps) {
404
+ const metameDir = deps.metameDir || path.join(os.homedir(), '.metame');
405
+ const statePath = path.join(metameDir, 'memory', 'now', projectKey + '.md');
406
+
407
+ const { phase, mission, history } = replayEventLog(projectKey, deps);
408
+
409
+ const rs = deps.loadState().reactive?.[projectKey] || {};
410
+ const projectName = config.projects?.[projectKey]?.name || projectKey;
411
+
412
+ const round = Math.max(1, history.filter(h => h.phase === 'topic').length);
413
+
414
+ const lines = [
415
+ `# ${projectName} status`,
416
+ `project: "${mission?.title || 'unknown'}"`,
417
+ `phase: ${phase || 'topic'}`,
418
+ `status: ${rs.status || 'idle'}`,
419
+ 'waiting_for: ""',
420
+ `round: ${round}`,
421
+ `last_update: "${new Date().toISOString()}"`,
422
+ '',
423
+ '# Phase history (from event log)',
424
+ ];
425
+
426
+ for (const h of history) {
427
+ lines.push(` - phase: ${h.phase}`);
428
+ lines.push(` date: "${h.date}"`);
429
+ }
430
+
431
+ fs.mkdirSync(path.dirname(statePath), { recursive: true });
432
+ fs.writeFileSync(statePath, lines.join('\n'), 'utf8');
433
+
434
+ return statePath;
435
+ }
436
+
437
+ /**
438
+ * Periodic reconciliation check for all perpetual projects.
439
+ * Zero-token: pure state file inspection, no LLM calls.
440
+ */
441
+ function reconcilePerpetualProjects(config, deps) {
442
+ const projects = config.projects || {};
443
+ for (const [key, proj] of Object.entries(projects)) {
444
+ if (!proj.reactive) continue;
445
+
446
+ const st = deps.loadState();
447
+ const rs = st.reactive?.[key];
448
+ if (!rs || rs.status !== 'running') continue;
449
+
450
+ const lastUpdate = new Date(rs.updated_at).getTime();
451
+ if (!Number.isFinite(lastUpdate)) {
452
+ deps.log('WARN', `Reconcile: ${key} has invalid updated_at: ${rs.updated_at}`);
453
+ continue;
454
+ }
455
+ const staleMinutes = proj.stale_timeout_minutes || 120;
456
+ const staleThreshold = staleMinutes * 60 * 1000;
457
+
458
+ if (Date.now() - lastUpdate > staleThreshold) {
459
+ deps.log('WARN', `Reconcile: ${key} stuck since ${rs.updated_at}`);
460
+ setReactiveStatus(st, key, 'stale', 'no_activity');
461
+ deps.saveState(st);
462
+ appendEvent(key, { type: 'STALE', last_signal: rs.last_signal || '' }, deps.metameDir);
463
+ if (deps.notifyUser) {
464
+ const pName = proj.name || key;
465
+ deps.notifyUser(`⚠️ ${pName} stale: no activity for ${staleMinutes}+ minutes (last signal: ${rs.last_signal || 'none'})`);
466
+ }
467
+ }
468
+ }
469
+ }
470
+
249
471
  // ── Main handler ────────────────────────────────────────────────
250
472
 
251
473
  /**
@@ -269,7 +491,17 @@ function runCompletionHooks(projectKey, projectCwd, deps) {
269
491
  function handleReactiveOutput(targetProject, output, config, deps) {
270
492
  if (!config || !config.projects) return;
271
493
 
272
- const signals = parseReactiveSignals(output);
494
+ // Scoped event logger — uses deps.metameDir for test isolation
495
+ const logEvent = (key, event) => appendEvent(key, event, deps.metameDir);
496
+
497
+ // Resolve manifest for completion signal
498
+ const projectCwd = isReactiveParent(targetProject, config)
499
+ ? resolveProjectCwd(targetProject, config)
500
+ : resolveProjectCwd(findReactiveParent(targetProject, config), config);
501
+ const manifest = projectCwd ? loadProjectManifest(projectCwd) : null;
502
+ const completionSignal = manifest?.completion_signal || 'MISSION_COMPLETE';
503
+
504
+ const signals = parseReactiveSignals(output, completionSignal);
273
505
  const hasSignals = signals.directives.length > 0 || signals.complete;
274
506
 
275
507
  // ── Case 1: targetProject is a reactive parent ──
@@ -277,39 +509,46 @@ function handleReactiveOutput(targetProject, output, config, deps) {
277
509
  if (!hasSignals) return;
278
510
 
279
511
  const projectKey = targetProject;
512
+ const pName = config.projects[projectKey]?.name || projectKey;
280
513
  const st = deps.loadState();
281
514
  const rs = getReactiveState(st, projectKey);
282
515
 
283
- // RESEARCH_COMPLETE takes priority
516
+ // Mission complete takes priority
284
517
  if (signals.complete) {
285
- deps.log('INFO', `Reactive: ${projectKey} research completed`);
518
+ deps.log('INFO', `Reactive: ${projectKey} mission completed`);
286
519
  setReactiveStatus(st, projectKey, 'completed', '');
287
520
  st.reactive[projectKey].depth = 0;
288
- rs.last_signal = 'RESEARCH_COMPLETE';
521
+ rs.last_signal = 'MISSION_COMPLETE';
289
522
  deps.saveState(st);
290
-
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 科研课题已完成并归档。无待处理课题,系统进入等待。';
523
+ logEvent(projectKey, { type: 'MISSION_COMPLETE' });
524
+
525
+ // Run completion hooks (archive + next mission)
526
+ const pCwd = resolveProjectCwd(projectKey, config);
527
+ if (pCwd) {
528
+ const completionResult = runCompletionHooks(projectKey, pCwd, deps);
529
+ if (completionResult.archived) {
530
+ logEvent(projectKey, { type: 'ARCHIVE', path: pCwd });
531
+ }
532
+ const notifyMsg = completionResult.nextMission
533
+ ? `\u2705 ${pName} mission completed. Next: ${completionResult.nextMission}`
534
+ : `\u2705 ${pName} mission completed. No pending missions — entering idle.`;
298
535
  if (deps.notifyUser) deps.notifyUser(notifyMsg);
299
536
 
300
- // Auto-start next topic if available — requires budget to be OK
301
- if (completionResult.nextTopic && completionResult.nextTopicPrompt) {
537
+ // Auto-start next mission if available — requires budget to be OK
538
+ if (completionResult.nextMission && completionResult.nextMissionPrompt) {
302
539
  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}" 已就绪但预算不足,暂不启动`);
540
+ deps.log('WARN', `Reactive: budget exceeded, skipping auto-start for ${projectKey}`);
541
+ logEvent(projectKey, { type: 'BUDGET_LIMIT', action: 'skip_next_mission' });
542
+ if (deps.notifyUser) deps.notifyUser(`\u26a0\ufe0f Next mission "${completionResult.nextMission}" ready but budget exceeded`);
305
543
  } else {
306
- deps.log('INFO', `Reactive: auto-starting next topic for ${projectKey}: ${completionResult.nextTopic}`);
544
+ deps.log('INFO', `Reactive: auto-starting next mission for ${projectKey}: ${completionResult.nextMission}`);
545
+ logEvent(projectKey, { type: 'MISSION_START', mission_id: completionResult.nextMissionId || '', mission_title: completionResult.nextMission });
307
546
  setReactiveStatus(st, projectKey, 'running', '');
308
547
  st.reactive[projectKey].depth = 0;
309
548
  deps.saveState(st);
310
549
  deps.handleDispatchItem({
311
550
  target: projectKey,
312
- prompt: completionResult.nextTopicPrompt,
551
+ prompt: completionResult.nextMissionPrompt,
313
552
  from: '_system',
314
553
  _reactive: true,
315
554
  new_session: true,
@@ -317,7 +556,7 @@ function handleReactiveOutput(targetProject, output, config, deps) {
317
556
  }
318
557
  }
319
558
  } else {
320
- if (deps.notifyUser) deps.notifyUser('\u2705 科研课题已完成');
559
+ if (deps.notifyUser) deps.notifyUser(`\u2705 ${pName} mission completed`);
321
560
  }
322
561
  return;
323
562
  }
@@ -330,17 +569,19 @@ function handleReactiveOutput(targetProject, output, config, deps) {
330
569
  deps.log('WARN', `Reactive: budget exceeded, pausing ${projectKey}`);
331
570
  setReactiveStatus(st, projectKey, 'paused', 'budget_exceeded');
332
571
  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');
572
+ logEvent(projectKey, { type: 'BUDGET_LIMIT', action: 'paused' });
573
+ if (deps.notifyUser) deps.notifyUser(`\u26a0\ufe0f ${pName} paused: daily budget exceeded`);
334
574
  return;
335
575
  }
336
576
 
337
- // Depth gate
338
- const maxDepth = rs.max_depth || 50;
577
+ // Depth gate (manifest max_depth overrides default)
578
+ const maxDepth = manifest?.max_depth || rs.max_depth || 50;
339
579
  if (rs.depth >= maxDepth) {
340
580
  deps.log('WARN', `Reactive: depth ${rs.depth} >= ${maxDepth}, pausing ${projectKey}`);
341
581
  setReactiveStatus(st, projectKey, 'paused', 'depth_exceeded');
342
582
  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}`);
583
+ logEvent(projectKey, { type: 'DEPTH_LIMIT', depth: rs.depth, action: 'paused' });
584
+ if (deps.notifyUser) deps.notifyUser(`\u26a0\ufe0f ${pName} paused: depth limit ${maxDepth} reached`);
344
585
  return;
345
586
  }
346
587
 
@@ -351,6 +592,7 @@ function handleReactiveOutput(targetProject, output, config, deps) {
351
592
 
352
593
  // Dispatch each directive with fresh session
353
594
  for (const d of signals.directives) {
595
+ logEvent(projectKey, { type: 'DISPATCH', target: d.target, prompt: d.prompt.slice(0, 200) });
354
596
  deps.handleDispatchItem({
355
597
  target: d.target,
356
598
  prompt: d.prompt,
@@ -366,6 +608,7 @@ function handleReactiveOutput(targetProject, output, config, deps) {
366
608
  const parentKey = findReactiveParent(targetProject, config);
367
609
  if (!parentKey || !isReactiveParent(parentKey, config)) return;
368
610
 
611
+ const pName = config.projects[parentKey]?.name || parentKey;
369
612
  const st = deps.loadState();
370
613
 
371
614
  // Budget gate
@@ -373,18 +616,21 @@ function handleReactiveOutput(targetProject, output, config, deps) {
373
616
  deps.log('WARN', `Reactive: budget exceeded, pausing ${parentKey} (via member ${targetProject})`);
374
617
  setReactiveStatus(st, parentKey, 'paused', 'budget_exceeded');
375
618
  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');
619
+ logEvent(parentKey, { type: 'BUDGET_LIMIT', action: 'paused', trigger: targetProject });
620
+ if (deps.notifyUser) deps.notifyUser(`\u26a0\ufe0f ${pName} paused: daily budget exceeded`);
377
621
  return;
378
622
  }
379
623
 
380
- // Depth gate
624
+ // Depth gate (manifest max_depth overrides default)
381
625
  const rs = getReactiveState(st, parentKey);
382
- const maxDepth = rs.max_depth || 50;
626
+ const parentManifestForDepth = projectCwd ? loadProjectManifest(projectCwd) : null;
627
+ const maxDepth = parentManifestForDepth?.max_depth || rs.max_depth || 50;
383
628
  if (rs.depth >= maxDepth) {
384
629
  deps.log('WARN', `Reactive: depth ${rs.depth} >= ${maxDepth}, pausing ${parentKey} (via member ${targetProject})`);
385
630
  setReactiveStatus(st, parentKey, 'paused', 'depth_exceeded');
386
631
  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}`);
632
+ logEvent(parentKey, { type: 'DEPTH_LIMIT', depth: rs.depth, action: 'paused' });
633
+ if (deps.notifyUser) deps.notifyUser(`\u26a0\ufe0f ${pName} paused: depth limit ${maxDepth} reached`);
388
634
  return;
389
635
  }
390
636
 
@@ -393,21 +639,58 @@ function handleReactiveOutput(targetProject, output, config, deps) {
393
639
  rs.last_signal = 'MEMBER_COMPLETE';
394
640
  rs.updated_at = new Date().toISOString();
395
641
  deps.saveState(st);
642
+ logEvent(parentKey, { type: 'MEMBER_COMPLETE', member: targetProject, summary_length: output.length });
396
643
 
397
644
  // Run verifier if available
398
645
  const verifyResult = deps.runVerifier
399
646
  ? deps.runVerifier(parentKey, config)
400
647
  : runProjectVerifier(parentKey, config, deps);
401
648
 
649
+ // Log verifier result as event
650
+ if (verifyResult) {
651
+ logEvent(parentKey, {
652
+ type: 'PHASE_GATE',
653
+ phase: verifyResult.phase || '',
654
+ passed: !!verifyResult.passed,
655
+ details: (verifyResult.details || '').slice(0, 500),
656
+ artifacts: verifyResult.artifacts || [],
657
+ });
658
+ }
659
+
660
+ // DESIGN CONTRACT: Error Semantic Isolation
661
+ // If verifier reports infrastructure failure (_infraFailure),
662
+ // pause the project and notify user — do NOT blame the agent.
663
+ if (verifyResult?._infraFailure) {
664
+ deps.log('WARN', `Verifier infra failure for ${parentKey}: ${verifyResult.details}`);
665
+ setReactiveStatus(st, parentKey, 'paused', 'infra_failure');
666
+ deps.saveState(st);
667
+ logEvent(parentKey, { type: 'INFRA_PAUSE', details: verifyResult.details });
668
+ if (deps.notifyUser) deps.notifyUser(`\u26a0\ufe0f ${pName} paused: external API unavailable. Not an agent error.`);
669
+ return; // Do NOT wake agent
670
+ }
671
+
672
+ // Update progress.tsv projection
673
+ const parentCwd = resolveProjectCwd(parentKey, config);
674
+ if (parentCwd) {
675
+ try { projectProgressTsv(parentCwd, parentKey, deps.metameDir); } catch { /* non-critical */ }
676
+ }
677
+
402
678
  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验证器未配置或不可用,请谨慎推进阶段';
679
+ ? `\n\n[Verifier] phase=${verifyResult.phase} passed=${verifyResult.passed}\n${verifyResult.details}${verifyResult.hints?.length ? '\nHints: ' + verifyResult.hints.join('; ') : ''}`
680
+ : '\n\n[Verifier] not configured — proceed with caution';
681
+
682
+ // Generate state file from event log BEFORE waking parent (event sourcing projection)
683
+ if (parentCwd) {
684
+ try { generateStateFile(parentKey, config, deps); } catch { /* non-critical */ }
685
+ }
405
686
 
406
687
  // Trigger parent with member's output summary
688
+ const parentManifest = parentCwd ? loadProjectManifest(parentCwd) : null;
689
+ const signal = parentManifest?.completion_signal || 'MISSION_COMPLETE';
407
690
  const summary = output.slice(0, 1200);
408
691
  deps.handleDispatchItem({
409
692
  target: parentKey,
410
- prompt: `[团队成员交付] ${targetProject} 完成任务。\n\n产出摘要:\n${summary}${verifierBlock}\n\n请阅读产出,评估质量,更新 now/${parentKey}.md,然后决定下一步。\n如需派发新任务,在回复末尾使用 NEXT_DISPATCH 指令。如研究全部完成,输出 RESEARCH_COMPLETE。`,
693
+ prompt: `[Team delivery] ${targetProject} completed task.\n\nOutput summary:\n${summary}${verifierBlock}\n\nEvaluate quality and decide next step.\nTo dispatch tasks, use NEXT_DISPATCH.\nWhen all tasks are done, output ${signal}.`,
411
694
  from: targetProject,
412
695
  _reactive: true,
413
696
  new_session: true,
@@ -417,5 +700,7 @@ function handleReactiveOutput(targetProject, output, config, deps) {
417
700
  module.exports = {
418
701
  handleReactiveOutput,
419
702
  parseReactiveSignals,
420
- __test: { runProjectVerifier, readPhaseFromState, resolveProjectCwd },
703
+ reconcilePerpetualProjects,
704
+ replayEventLog,
705
+ __test: { runProjectVerifier, readPhaseFromState, resolveProjectCwd, appendEvent, projectProgressTsv, generateStateFile, loadProjectManifest, resolveProjectScripts },
421
706
  };