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.
- package/README.md +49 -6
- package/index.js +266 -72
- package/package.json +7 -3
- package/scripts/daemon-admin-commands.js +34 -0
- package/scripts/daemon-agent-commands.js +6 -2
- package/scripts/daemon-bridges.js +41 -10
- package/scripts/daemon-claude-engine.js +128 -29
- package/scripts/daemon-command-router.js +16 -0
- package/scripts/daemon-command-session-route.js +3 -1
- package/scripts/daemon-default.yaml +3 -1
- package/scripts/daemon-engine-runtime.js +1 -5
- package/scripts/daemon-message-pipeline.js +113 -44
- package/scripts/daemon-ops-commands.js +25 -11
- package/scripts/daemon-reactive-lifecycle.js +757 -76
- package/scripts/daemon-session-commands.js +3 -2
- package/scripts/daemon-session-store.js +82 -27
- package/scripts/daemon-team-dispatch.js +21 -5
- package/scripts/daemon-utils.js +3 -1
- package/scripts/daemon.js +80 -2
- package/scripts/distill.js +1 -1
- package/scripts/docs/file-transfer.md +1 -0
- 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/hooks/intent-file-transfer.js +2 -1
- package/scripts/hooks/intent-perpetual.js +109 -0
- package/scripts/hooks/intent-research.js +112 -0
- package/scripts/intent-registry.js +4 -0
- package/scripts/memory-extract.js +29 -1
- package/scripts/memory-nightly-reflect.js +104 -0
- package/scripts/ops-mission-queue.js +258 -0
- package/scripts/ops-verifier.js +197 -0
- package/scripts/signal-capture.js +3 -3
- package/scripts/skill-evolution.js +11 -2
- package/skills/agent-browser/SKILL.md +153 -0
- package/skills/agent-reach/SKILL.md +66 -0
- package/skills/agent-reach/evolution.json +13 -0
- package/skills/deep-research/SKILL.md +77 -0
- package/skills/find-skills/SKILL.md +133 -0
- package/skills/heartbeat-task-manager/SKILL.md +63 -0
- package/skills/macos-local-orchestrator/SKILL.md +192 -0
- package/skills/macos-local-orchestrator/agents/openai.yaml +4 -0
- package/skills/macos-local-orchestrator/references/tooling-landscape.md +70 -0
- package/skills/macos-mail-calendar/SKILL.md +394 -0
- package/skills/mcp-installer/SKILL.md +138 -0
- package/skills/skill-creator/LICENSE.txt +202 -0
- package/skills/skill-creator/README.md +72 -0
- package/skills/skill-creator/SKILL.md +96 -0
- package/skills/skill-creator/evolution.json +6 -0
- package/skills/skill-creator/references/creation-guide.md +116 -0
- package/skills/skill-creator/references/evolution-guide.md +74 -0
- package/skills/skill-creator/references/output-patterns.md +82 -0
- package/skills/skill-creator/references/workflows.md +28 -0
- package/skills/skill-creator/scripts/align_all.py +32 -0
- package/skills/skill-creator/scripts/auto_evolve_hook.js +247 -0
- package/skills/skill-creator/scripts/init_skill.py +303 -0
- package/skills/skill-creator/scripts/merge_evolution.py +70 -0
- package/skills/skill-creator/scripts/package_skill.py +110 -0
- package/skills/skill-creator/scripts/quick_validate.py +103 -0
- package/skills/skill-creator/scripts/setup.py +141 -0
- package/skills/skill-creator/scripts/smart_stitch.py +82 -0
- package/skills/skill-manager/SKILL.md +112 -0
- package/skills/skill-manager/scripts/delete_skill.py +31 -0
- package/skills/skill-manager/scripts/list_skills.py +61 -0
- package/skills/skill-manager/scripts/scan_and_check.py +125 -0
- package/skills/skill-manager/scripts/sync_index.py +144 -0
- 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
|
-
*
|
|
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,20 @@ 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
|
-
const
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
)
|
|
141
|
-
const phase =
|
|
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(
|
|
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: ['
|
|
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
|
|
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
|
-
|
|
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
|
|
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.
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
const
|
|
202
|
-
//
|
|
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(
|
|
205
|
-
cwd: projectCwd, encoding: 'utf8', timeout: 10000, env:
|
|
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
|
|
212
|
-
cwd: projectCwd, encoding: 'utf8', timeout: 10000, env:
|
|
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
|
|
268
|
+
deps.log('INFO', `Reactive: completed mission ${activeTopic.id}: ${activeTopic.title}`);
|
|
215
269
|
}
|
|
216
270
|
}
|
|
217
271
|
} catch (e) {
|
|
218
|
-
deps.log('WARN', `Reactive:
|
|
272
|
+
deps.log('WARN', `Reactive: mission complete failed: ${e.message}`);
|
|
219
273
|
}
|
|
220
|
-
// 2b. Get next pending
|
|
274
|
+
// 2b. Get next pending mission
|
|
221
275
|
try {
|
|
222
|
-
const nextOut = execSync(
|
|
223
|
-
cwd: projectCwd, encoding: 'utf8', timeout: 10000, env:
|
|
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
|
|
230
|
-
cwd: projectCwd, encoding: 'utf8', timeout: 10000, env:
|
|
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:
|
|
286
|
+
deps.log('WARN', `Reactive: mission activate failed: ${e.message}`);
|
|
234
287
|
}
|
|
235
|
-
result.
|
|
236
|
-
result.
|
|
237
|
-
|
|
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:
|
|
294
|
+
deps.log('WARN', `Reactive: mission queue query failed for ${projectKey}: ${e.message}`);
|
|
241
295
|
}
|
|
242
|
-
} else if (!result.archived && fs.existsSync(
|
|
243
|
-
deps.log('WARN', `Reactive: skipping
|
|
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
|
-
|
|
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
|
-
//
|
|
890
|
+
// Mission complete takes priority
|
|
284
891
|
if (signals.complete) {
|
|
285
|
-
deps.log('INFO', `Reactive: ${projectKey}
|
|
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 = '
|
|
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
|
|
292
|
-
const
|
|
293
|
-
if (
|
|
294
|
-
const completionResult = runCompletionHooks(projectKey,
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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
|
|
301
|
-
if (completionResult.
|
|
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
|
|
304
|
-
|
|
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
|
|
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.
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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[
|
|
404
|
-
: '\n\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
|
|
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: `[
|
|
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
|
-
|
|
1099
|
+
reconcilePerpetualProjects,
|
|
1100
|
+
replayEventLog,
|
|
1101
|
+
__test: { runProjectVerifier, readPhaseFromState, resolveProjectCwd, appendEvent, projectProgressTsv, generateStateFile, loadProjectManifest, resolveProjectScripts, parseEventLog, buildRunningMemory, scanRelevantArtifacts, buildWorkingMemory, persistMemoryFiles, extractInlineFacts, extractOutputSummary },
|
|
421
1102
|
};
|