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.
- package/README.md +49 -6
- package/index.js +202 -65
- package/package.json +6 -3
- package/scripts/daemon-admin-commands.js +34 -0
- package/scripts/daemon-bridges.js +18 -1
- package/scripts/daemon-claude-engine.js +41 -1
- package/scripts/daemon-default.yaml +3 -1
- package/scripts/daemon-ops-commands.js +25 -11
- package/scripts/daemon-reactive-lifecycle.js +355 -70
- package/scripts/daemon.js +79 -2
- package/scripts/distill.js +1 -1
- package/scripts/docs/maintenance-manual.md +55 -2
- package/scripts/docs/pointer-map.md +34 -0
- package/scripts/feishu-adapter.js +25 -0
- package/scripts/memory-extract.js +29 -1
- package/scripts/memory-nightly-reflect.js +104 -0
- package/scripts/signal-capture.js +3 -3
- package/scripts/skill-evolution.js +11 -2
|
@@ -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
|
-
*
|
|
12
|
-
*
|
|
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.
|
|
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
|
|
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
|
|
135
|
-
|
|
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(
|
|
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: ['
|
|
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
|
|
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
|
-
|
|
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
|
|
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.
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
const
|
|
202
|
-
//
|
|
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(
|
|
205
|
-
cwd: projectCwd, encoding: 'utf8', timeout: 10000, env:
|
|
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
|
|
212
|
-
cwd: projectCwd, encoding: 'utf8', timeout: 10000, env:
|
|
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
|
|
267
|
+
deps.log('INFO', `Reactive: completed mission ${activeTopic.id}: ${activeTopic.title}`);
|
|
215
268
|
}
|
|
216
269
|
}
|
|
217
270
|
} catch (e) {
|
|
218
|
-
deps.log('WARN', `Reactive:
|
|
271
|
+
deps.log('WARN', `Reactive: mission complete failed: ${e.message}`);
|
|
219
272
|
}
|
|
220
|
-
// 2b. Get next pending
|
|
273
|
+
// 2b. Get next pending mission
|
|
221
274
|
try {
|
|
222
|
-
const nextOut = execSync(
|
|
223
|
-
cwd: projectCwd, encoding: 'utf8', timeout: 10000, env:
|
|
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
|
|
230
|
-
cwd: projectCwd, encoding: 'utf8', timeout: 10000, env:
|
|
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:
|
|
285
|
+
deps.log('WARN', `Reactive: mission activate failed: ${e.message}`);
|
|
234
286
|
}
|
|
235
|
-
result.
|
|
236
|
-
result.
|
|
237
|
-
|
|
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:
|
|
293
|
+
deps.log('WARN', `Reactive: mission queue query failed for ${projectKey}: ${e.message}`);
|
|
241
294
|
}
|
|
242
|
-
} else if (!result.archived && fs.existsSync(
|
|
243
|
-
deps.log('WARN', `Reactive: skipping
|
|
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
|
-
|
|
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
|
-
//
|
|
516
|
+
// Mission complete takes priority
|
|
284
517
|
if (signals.complete) {
|
|
285
|
-
deps.log('INFO', `Reactive: ${projectKey}
|
|
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 = '
|
|
521
|
+
rs.last_signal = 'MISSION_COMPLETE';
|
|
289
522
|
deps.saveState(st);
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
const
|
|
296
|
-
|
|
297
|
-
: '
|
|
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
|
|
301
|
-
if (completionResult.
|
|
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
|
|
304
|
-
|
|
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
|
|
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.
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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[
|
|
404
|
-
: '\n\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: `[
|
|
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
|
-
|
|
703
|
+
reconcilePerpetualProjects,
|
|
704
|
+
replayEventLog,
|
|
705
|
+
__test: { runProjectVerifier, readPhaseFromState, resolveProjectCwd, appendEvent, projectProgressTsv, generateStateFile, loadProjectManifest, resolveProjectScripts },
|
|
421
706
|
};
|