metame-cli 1.5.8 → 1.5.10
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/index.js +19 -2
- package/package.json +1 -1
- package/scripts/daemon-bridges.js +36 -44
- package/scripts/daemon-checkpoints.js +38 -24
- package/scripts/daemon-claude-engine.js +238 -58
- package/scripts/daemon-command-router.js +6 -125
- package/scripts/daemon-command-session-route.js +7 -1
- package/scripts/daemon-engine-runtime.js +8 -1
- package/scripts/daemon-exec-commands.js +36 -25
- package/scripts/daemon-message-pipeline.js +268 -0
- package/scripts/daemon-ops-commands.js +12 -10
- package/scripts/daemon-reactive-lifecycle.js +421 -0
- package/scripts/daemon-session-store.js +24 -24
- package/scripts/daemon-task-scheduler.js +90 -112
- package/scripts/daemon-utils.js +55 -0
- package/scripts/daemon-warm-pool.js +162 -0
- package/scripts/daemon-worktrees.js +129 -0
- package/scripts/daemon.js +31 -3
- package/scripts/docs/orphan-files-review.md +72 -0
- package/scripts/hooks/intent-auto-rules.js +50 -0
- package/scripts/verify-reactive-claude-md.js +101 -0
- package/scripts/daemon.yaml +0 -356
|
@@ -0,0 +1,421 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const path = require('path');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const os = require('os');
|
|
6
|
+
const { execSync } = require('child_process');
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* daemon-reactive-lifecycle.js — Reactive Loop Lifecycle Module
|
|
10
|
+
*
|
|
11
|
+
* Extracts reactive dispatch logic from daemon.js into a testable,
|
|
12
|
+
* self-contained module with four hard gates:
|
|
13
|
+
* 1. Budget gate — pauses loop when daily budget exhausted
|
|
14
|
+
* 2. Depth gate — pauses loop when depth counter hits max
|
|
15
|
+
* 3. Fresh session — every reactive dispatch uses new_session: true
|
|
16
|
+
* 4. RESEARCH_COMPLETE — resets depth, marks completed, notifies user
|
|
17
|
+
* 5. Verifier hook — runs project verifier before waking parent
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
// ── Signal parsing ──────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
// Signal patterns (created per-call to avoid global regex lastIndex state)
|
|
23
|
+
// Supports two formats (both common in LLM output):
|
|
24
|
+
// NEXT_DISPATCH: target "prompt here" (quoted, single-line)
|
|
25
|
+
// NEXT_DISPATCH: target: prompt here (colon-separated, until next directive or end)
|
|
26
|
+
const QUOTED_PATTERN = /NEXT_DISPATCH:\s*(\S+)\s+"([^"]+)"/g;
|
|
27
|
+
const COLON_PATTERN = /NEXT_DISPATCH\s*:\s*(\S+)\s*:\s*(.+?)(?=\nNEXT_DISPATCH\s*:|$)/gs;
|
|
28
|
+
const RESEARCH_COMPLETE_RE = /RESEARCH_COMPLETE/;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Parse reactive signals from agent output.
|
|
32
|
+
* Pure function — no side effects.
|
|
33
|
+
*
|
|
34
|
+
* @param {string} output - Raw agent output text
|
|
35
|
+
* @returns {{ directives: Array<{target: string, prompt: string}>, complete: boolean }}
|
|
36
|
+
*/
|
|
37
|
+
function parseReactiveSignals(output) {
|
|
38
|
+
const directives = [];
|
|
39
|
+
let match;
|
|
40
|
+
// Try quoted format first (preferred, documented in CLAUDE.md)
|
|
41
|
+
// Create fresh regex each call to avoid global lastIndex state pollution
|
|
42
|
+
const quotedRe = new RegExp(QUOTED_PATTERN.source, QUOTED_PATTERN.flags);
|
|
43
|
+
while ((match = quotedRe.exec(output)) !== null) {
|
|
44
|
+
const target = match[1].trim();
|
|
45
|
+
const prompt = match[2].trim();
|
|
46
|
+
if (target && prompt) directives.push({ target, prompt });
|
|
47
|
+
}
|
|
48
|
+
// Fallback: colon-separated format (tolerant of LLM variation)
|
|
49
|
+
if (directives.length === 0) {
|
|
50
|
+
const colonRe = new RegExp(COLON_PATTERN.source, COLON_PATTERN.flags);
|
|
51
|
+
while ((match = colonRe.exec(output)) !== null) {
|
|
52
|
+
const target = match[1].trim();
|
|
53
|
+
const prompt = match[2].trim();
|
|
54
|
+
if (target && prompt) directives.push({ target, prompt });
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
const complete = RESEARCH_COMPLETE_RE.test(output);
|
|
58
|
+
return { directives, complete };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// ── Internal state helpers (not exported) ───────────────────────
|
|
62
|
+
|
|
63
|
+
function getReactiveState(state, projectKey) {
|
|
64
|
+
if (!state.reactive) state.reactive = {};
|
|
65
|
+
if (!state.reactive[projectKey]) {
|
|
66
|
+
state.reactive[projectKey] = {
|
|
67
|
+
depth: 0,
|
|
68
|
+
max_depth: 50,
|
|
69
|
+
status: 'idle',
|
|
70
|
+
pause_reason: '',
|
|
71
|
+
last_signal: '',
|
|
72
|
+
updated_at: new Date().toISOString(),
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
return state.reactive[projectKey];
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function setReactiveStatus(state, projectKey, status, reason) {
|
|
79
|
+
const rs = getReactiveState(state, projectKey);
|
|
80
|
+
rs.status = status;
|
|
81
|
+
rs.pause_reason = reason || '';
|
|
82
|
+
rs.updated_at = new Date().toISOString();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Find the reactive parent project key for a given team member.
|
|
87
|
+
* Returns the parent key string, or null if not found.
|
|
88
|
+
*/
|
|
89
|
+
function findReactiveParent(targetProject, config) {
|
|
90
|
+
if (!config || !config.projects) return null;
|
|
91
|
+
for (const [parentKey, proj] of Object.entries(config.projects)) {
|
|
92
|
+
if (!proj || !Array.isArray(proj.team)) continue;
|
|
93
|
+
if (proj.team.some(m => m.key === targetProject)) {
|
|
94
|
+
return parentKey;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Check if a project is configured as a reactive parent.
|
|
102
|
+
* A reactive parent has a `reactive` truthy flag in its project config.
|
|
103
|
+
*/
|
|
104
|
+
function isReactiveParent(projectKey, config) {
|
|
105
|
+
if (!config || !config.projects) return false;
|
|
106
|
+
const proj = config.projects[projectKey];
|
|
107
|
+
return !!(proj && proj.reactive);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ── Verifier helpers ────────────────────────────────────────────
|
|
111
|
+
|
|
112
|
+
function resolveProjectCwd(projectKey, config) {
|
|
113
|
+
const proj = config.projects?.[projectKey];
|
|
114
|
+
if (!proj || !proj.cwd) return null;
|
|
115
|
+
return proj.cwd.replace(/^~/, os.homedir());
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function readPhaseFromState(statePath) {
|
|
119
|
+
try {
|
|
120
|
+
const content = fs.readFileSync(statePath, 'utf8');
|
|
121
|
+
const match = content.match(/^phase:\s*(\S+)/m);
|
|
122
|
+
return match ? match[1] : '';
|
|
123
|
+
} catch { return ''; }
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Run project-level verifier script if it exists.
|
|
128
|
+
* Returns parsed JSON result or null if no verifier / error.
|
|
129
|
+
*/
|
|
130
|
+
function runProjectVerifier(projectKey, config, deps) {
|
|
131
|
+
const projectCwd = resolveProjectCwd(projectKey, config);
|
|
132
|
+
if (!projectCwd) return null;
|
|
133
|
+
|
|
134
|
+
const verifierPath = path.join(projectCwd, 'scripts', 'research-verifier.js');
|
|
135
|
+
if (!fs.existsSync(verifierPath)) return null;
|
|
136
|
+
|
|
137
|
+
const statePath = path.join(
|
|
138
|
+
deps.metameDir || path.join(os.homedir(), '.metame'),
|
|
139
|
+
'memory', 'now', `${projectKey}.md`
|
|
140
|
+
);
|
|
141
|
+
const phase = readPhaseFromState(statePath);
|
|
142
|
+
|
|
143
|
+
try {
|
|
144
|
+
const output = execSync('node scripts/research-verifier.js', {
|
|
145
|
+
cwd: projectCwd,
|
|
146
|
+
encoding: 'utf8',
|
|
147
|
+
timeout: 15000,
|
|
148
|
+
env: {
|
|
149
|
+
...process.env,
|
|
150
|
+
VERIFIER_CWD: projectCwd,
|
|
151
|
+
VERIFIER_PHASE: phase || '',
|
|
152
|
+
VERIFIER_STATE_PATH: statePath,
|
|
153
|
+
},
|
|
154
|
+
}).trim();
|
|
155
|
+
return JSON.parse(output);
|
|
156
|
+
} catch (e) {
|
|
157
|
+
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 脚本'] };
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Run project-level completion hooks (archive + topic pool).
|
|
164
|
+
* Platform only calls scripts if they exist — no business logic here.
|
|
165
|
+
* @returns {{ archived: boolean, nextTopic: string|null, nextTopicPrompt: string|null }}
|
|
166
|
+
*/
|
|
167
|
+
function runCompletionHooks(projectKey, projectCwd, deps) {
|
|
168
|
+
const result = { archived: false, nextTopic: null, nextTopicPrompt: null };
|
|
169
|
+
|
|
170
|
+
// 1. Archive
|
|
171
|
+
const archiveScript = path.join(projectCwd, 'scripts', 'research-archive.js');
|
|
172
|
+
if (fs.existsSync(archiveScript)) {
|
|
173
|
+
try {
|
|
174
|
+
// Read project name from state file
|
|
175
|
+
const statePath = path.join(
|
|
176
|
+
deps.metameDir || path.join(os.homedir(), '.metame'),
|
|
177
|
+
'memory', 'now', `${projectKey}.md`
|
|
178
|
+
);
|
|
179
|
+
let projectName = projectKey;
|
|
180
|
+
try {
|
|
181
|
+
const stateContent = fs.readFileSync(statePath, 'utf8');
|
|
182
|
+
const m = stateContent.match(/^project:\s*"?(.+?)"?\s*$/m);
|
|
183
|
+
if (m) projectName = m[1];
|
|
184
|
+
} catch { /* use projectKey */ }
|
|
185
|
+
|
|
186
|
+
const archiveOut = execSync('node scripts/research-archive.js', {
|
|
187
|
+
cwd: projectCwd, encoding: 'utf8', timeout: 30000,
|
|
188
|
+
env: { ...process.env, ARCHIVE_CWD: projectCwd, ARCHIVE_PROJECT_NAME: projectName, ARCHIVE_STATE_PATH: statePath },
|
|
189
|
+
}).trim();
|
|
190
|
+
const archiveResult = JSON.parse(archiveOut);
|
|
191
|
+
result.archived = archiveResult.success === true;
|
|
192
|
+
deps.log('INFO', `Reactive: archive result for ${projectKey}: ${archiveOut.slice(0, 200)}`);
|
|
193
|
+
} catch (e) {
|
|
194
|
+
deps.log('WARN', `Reactive: archive failed for ${projectKey}: ${e.message}`);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
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
|
|
203
|
+
try {
|
|
204
|
+
const listOut = execSync('node scripts/topic-pool.js list', {
|
|
205
|
+
cwd: projectCwd, encoding: 'utf8', timeout: 10000, env: topicEnv,
|
|
206
|
+
}).trim();
|
|
207
|
+
const listResult = JSON.parse(listOut);
|
|
208
|
+
if (listResult.success && Array.isArray(listResult.topics)) {
|
|
209
|
+
const activeTopic = listResult.topics.find(t => t.status === 'active');
|
|
210
|
+
if (activeTopic) {
|
|
211
|
+
execSync(`node scripts/topic-pool.js complete ${activeTopic.id}`, {
|
|
212
|
+
cwd: projectCwd, encoding: 'utf8', timeout: 10000, env: topicEnv,
|
|
213
|
+
});
|
|
214
|
+
deps.log('INFO', `Reactive: completed active topic ${activeTopic.id}: ${activeTopic.title}`);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
} catch (e) {
|
|
218
|
+
deps.log('WARN', `Reactive: topic complete failed: ${e.message}`);
|
|
219
|
+
}
|
|
220
|
+
// 2b. Get next pending topic
|
|
221
|
+
try {
|
|
222
|
+
const nextOut = execSync('node scripts/topic-pool.js next', {
|
|
223
|
+
cwd: projectCwd, encoding: 'utf8', timeout: 10000, env: topicEnv,
|
|
224
|
+
}).trim();
|
|
225
|
+
const nextResult = JSON.parse(nextOut);
|
|
226
|
+
if (nextResult.success && nextResult.topic) {
|
|
227
|
+
// Activate the next topic
|
|
228
|
+
try {
|
|
229
|
+
execSync(`node scripts/topic-pool.js activate ${nextResult.topic.id}`, {
|
|
230
|
+
cwd: projectCwd, encoding: 'utf8', timeout: 10000, env: topicEnv,
|
|
231
|
+
});
|
|
232
|
+
} catch (e) {
|
|
233
|
+
deps.log('WARN', `Reactive: topic activate failed: ${e.message}`);
|
|
234
|
+
}
|
|
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}`);
|
|
238
|
+
}
|
|
239
|
+
} catch (e) {
|
|
240
|
+
deps.log('WARN', `Reactive: topic pool query failed for ${projectKey}: ${e.message}`);
|
|
241
|
+
}
|
|
242
|
+
} else if (!result.archived && fs.existsSync(topicScript)) {
|
|
243
|
+
deps.log('WARN', `Reactive: skipping topic pool for ${projectKey} — archive did not succeed`);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
return result;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
// ── Main handler ────────────────────────────────────────────────
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Handle reactive agent output. Called from outputHandler in dispatchTask.
|
|
253
|
+
* Responsible for: signal parsing, budget gate, depth gate, completion,
|
|
254
|
+
* constructing follow-up dispatches.
|
|
255
|
+
*
|
|
256
|
+
* @param {string} targetProject - The project key that produced the output
|
|
257
|
+
* @param {string} output - Raw output text
|
|
258
|
+
* @param {object} config - Full daemon config
|
|
259
|
+
* @param {object} deps - Injected dependencies
|
|
260
|
+
* @param {Function} deps.log - (level, msg) => void
|
|
261
|
+
* @param {Function} deps.loadState - () => state
|
|
262
|
+
* @param {Function} deps.saveState - (state) => void
|
|
263
|
+
* @param {Function} deps.checkBudget - (config, state) => boolean
|
|
264
|
+
* @param {Function} deps.handleDispatchItem - (item, config) => result
|
|
265
|
+
* @param {Function} [deps.notifyUser] - (msg) => void (Feishu notification)
|
|
266
|
+
* @param {Function} [deps.runVerifier] - (projectKey, config) => {passed,phase,details,artifacts,hints} | null
|
|
267
|
+
* @param {string} [deps.metameDir] - Override ~/.metame path (for testing)
|
|
268
|
+
*/
|
|
269
|
+
function handleReactiveOutput(targetProject, output, config, deps) {
|
|
270
|
+
if (!config || !config.projects) return;
|
|
271
|
+
|
|
272
|
+
const signals = parseReactiveSignals(output);
|
|
273
|
+
const hasSignals = signals.directives.length > 0 || signals.complete;
|
|
274
|
+
|
|
275
|
+
// ── Case 1: targetProject is a reactive parent ──
|
|
276
|
+
if (isReactiveParent(targetProject, config)) {
|
|
277
|
+
if (!hasSignals) return;
|
|
278
|
+
|
|
279
|
+
const projectKey = targetProject;
|
|
280
|
+
const st = deps.loadState();
|
|
281
|
+
const rs = getReactiveState(st, projectKey);
|
|
282
|
+
|
|
283
|
+
// RESEARCH_COMPLETE takes priority
|
|
284
|
+
if (signals.complete) {
|
|
285
|
+
deps.log('INFO', `Reactive: ${projectKey} research completed`);
|
|
286
|
+
setReactiveStatus(st, projectKey, 'completed', '');
|
|
287
|
+
st.reactive[projectKey].depth = 0;
|
|
288
|
+
rs.last_signal = 'RESEARCH_COMPLETE';
|
|
289
|
+
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 科研课题已完成并归档。无待处理课题,系统进入等待。';
|
|
298
|
+
if (deps.notifyUser) deps.notifyUser(notifyMsg);
|
|
299
|
+
|
|
300
|
+
// Auto-start next topic if available — requires budget to be OK
|
|
301
|
+
if (completionResult.nextTopic && completionResult.nextTopicPrompt) {
|
|
302
|
+
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}" 已就绪但预算不足,暂不启动`);
|
|
305
|
+
} else {
|
|
306
|
+
deps.log('INFO', `Reactive: auto-starting next topic for ${projectKey}: ${completionResult.nextTopic}`);
|
|
307
|
+
setReactiveStatus(st, projectKey, 'running', '');
|
|
308
|
+
st.reactive[projectKey].depth = 0;
|
|
309
|
+
deps.saveState(st);
|
|
310
|
+
deps.handleDispatchItem({
|
|
311
|
+
target: projectKey,
|
|
312
|
+
prompt: completionResult.nextTopicPrompt,
|
|
313
|
+
from: '_system',
|
|
314
|
+
_reactive: true,
|
|
315
|
+
new_session: true,
|
|
316
|
+
}, config);
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
} else {
|
|
320
|
+
if (deps.notifyUser) deps.notifyUser('\u2705 科研课题已完成');
|
|
321
|
+
}
|
|
322
|
+
return;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
// NEXT_DISPATCH processing
|
|
326
|
+
rs.last_signal = 'NEXT_DISPATCH';
|
|
327
|
+
|
|
328
|
+
// Budget gate
|
|
329
|
+
if (!deps.checkBudget(config, st)) {
|
|
330
|
+
deps.log('WARN', `Reactive: budget exceeded, pausing ${projectKey}`);
|
|
331
|
+
setReactiveStatus(st, projectKey, 'paused', 'budget_exceeded');
|
|
332
|
+
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');
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
// Depth gate
|
|
338
|
+
const maxDepth = rs.max_depth || 50;
|
|
339
|
+
if (rs.depth >= maxDepth) {
|
|
340
|
+
deps.log('WARN', `Reactive: depth ${rs.depth} >= ${maxDepth}, pausing ${projectKey}`);
|
|
341
|
+
setReactiveStatus(st, projectKey, 'paused', 'depth_exceeded');
|
|
342
|
+
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}`);
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
rs.depth += 1;
|
|
348
|
+
rs.status = 'running';
|
|
349
|
+
rs.updated_at = new Date().toISOString();
|
|
350
|
+
deps.saveState(st);
|
|
351
|
+
|
|
352
|
+
// Dispatch each directive with fresh session
|
|
353
|
+
for (const d of signals.directives) {
|
|
354
|
+
deps.handleDispatchItem({
|
|
355
|
+
target: d.target,
|
|
356
|
+
prompt: d.prompt,
|
|
357
|
+
from: projectKey,
|
|
358
|
+
_reactive: true,
|
|
359
|
+
new_session: true,
|
|
360
|
+
}, config);
|
|
361
|
+
}
|
|
362
|
+
return;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
// ── Case 2: targetProject is a team member of a reactive parent ──
|
|
366
|
+
const parentKey = findReactiveParent(targetProject, config);
|
|
367
|
+
if (!parentKey || !isReactiveParent(parentKey, config)) return;
|
|
368
|
+
|
|
369
|
+
const st = deps.loadState();
|
|
370
|
+
|
|
371
|
+
// Budget gate
|
|
372
|
+
if (!deps.checkBudget(config, st)) {
|
|
373
|
+
deps.log('WARN', `Reactive: budget exceeded, pausing ${parentKey} (via member ${targetProject})`);
|
|
374
|
+
setReactiveStatus(st, parentKey, 'paused', 'budget_exceeded');
|
|
375
|
+
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');
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// Depth gate
|
|
381
|
+
const rs = getReactiveState(st, parentKey);
|
|
382
|
+
const maxDepth = rs.max_depth || 50;
|
|
383
|
+
if (rs.depth >= maxDepth) {
|
|
384
|
+
deps.log('WARN', `Reactive: depth ${rs.depth} >= ${maxDepth}, pausing ${parentKey} (via member ${targetProject})`);
|
|
385
|
+
setReactiveStatus(st, parentKey, 'paused', 'depth_exceeded');
|
|
386
|
+
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}`);
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
rs.depth += 1;
|
|
392
|
+
rs.status = 'running';
|
|
393
|
+
rs.last_signal = 'MEMBER_COMPLETE';
|
|
394
|
+
rs.updated_at = new Date().toISOString();
|
|
395
|
+
deps.saveState(st);
|
|
396
|
+
|
|
397
|
+
// Run verifier if available
|
|
398
|
+
const verifyResult = deps.runVerifier
|
|
399
|
+
? deps.runVerifier(parentKey, config)
|
|
400
|
+
: runProjectVerifier(parentKey, config, deps);
|
|
401
|
+
|
|
402
|
+
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验证器未配置或不可用,请谨慎推进阶段';
|
|
405
|
+
|
|
406
|
+
// Trigger parent with member's output summary
|
|
407
|
+
const summary = output.slice(0, 1200);
|
|
408
|
+
deps.handleDispatchItem({
|
|
409
|
+
target: parentKey,
|
|
410
|
+
prompt: `[团队成员交付] ${targetProject} 完成任务。\n\n产出摘要:\n${summary}${verifierBlock}\n\n请阅读产出,评估质量,更新 now/${parentKey}.md,然后决定下一步。\n如需派发新任务,在回复末尾使用 NEXT_DISPATCH 指令。如研究全部完成,输出 RESEARCH_COMPLETE。`,
|
|
411
|
+
from: targetProject,
|
|
412
|
+
_reactive: true,
|
|
413
|
+
new_session: true,
|
|
414
|
+
}, config);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
module.exports = {
|
|
418
|
+
handleReactiveOutput,
|
|
419
|
+
parseReactiveSignals,
|
|
420
|
+
__test: { runProjectVerifier, readPhaseFromState, resolveProjectCwd },
|
|
421
|
+
};
|
|
@@ -841,6 +841,8 @@ function createSessionStore(deps) {
|
|
|
841
841
|
function writeSessionName(sessionId, cwd, name) {
|
|
842
842
|
void cwd;
|
|
843
843
|
try {
|
|
844
|
+
// Clear stale cache — JSONL may have just been created by spawnClaudeStreaming
|
|
845
|
+
clearSessionFileCache(sessionId);
|
|
844
846
|
const sessionFile = findSessionFile(sessionId);
|
|
845
847
|
if (!sessionFile) {
|
|
846
848
|
log('WARN', `writeSessionName: session file not found for ${sessionId.slice(0, 8)}`);
|
|
@@ -969,13 +971,26 @@ function createSessionStore(deps) {
|
|
|
969
971
|
function _isClaudeSessionValid(sessionId, normCwd) {
|
|
970
972
|
try {
|
|
971
973
|
const sessionFile = findSessionFile(sessionId);
|
|
972
|
-
if (!sessionFile)
|
|
974
|
+
if (!sessionFile) {
|
|
975
|
+
log('WARN', `[SessionValid] ${sessionId.slice(0, 8)}: JSONL file not found`);
|
|
976
|
+
return false;
|
|
977
|
+
}
|
|
973
978
|
|
|
974
979
|
// Try to read cwd/model from session JSONL file content (most reliable)
|
|
975
980
|
const metadata = _readClaudeSessionMetadata(sessionFile);
|
|
976
|
-
if (metadata.model && !metadata.model.startsWith('claude-'))
|
|
981
|
+
if (metadata.model && !metadata.model.startsWith('claude-')) {
|
|
982
|
+
log('WARN', `[SessionValid] ${sessionId.slice(0, 8)}: non-claude model "${metadata.model}"`);
|
|
983
|
+
return false;
|
|
984
|
+
}
|
|
977
985
|
if (metadata.cwd && path.resolve(metadata.cwd) === normCwd) return true;
|
|
978
|
-
if (metadata.cwd)
|
|
986
|
+
if (metadata.cwd) {
|
|
987
|
+
// CWD mismatch: the session was created for a different directory.
|
|
988
|
+
// However, if the JSONL file exists and has Claude content, the session is still
|
|
989
|
+
// usable — the cwd might have changed due to worktree cleanup, config reload, etc.
|
|
990
|
+
// Log the mismatch but allow resuming (Claude CLI handles cwd internally).
|
|
991
|
+
log('INFO', `[SessionValid] ${sessionId.slice(0, 8)}: cwd mismatch (jsonl="${metadata.cwd}" vs expected="${normCwd}") — allowing resume`);
|
|
992
|
+
return true;
|
|
993
|
+
}
|
|
979
994
|
for (const line of metadata.lines.slice(0, 20)) { // preserve tolerant parsing for malformed heads
|
|
980
995
|
try {
|
|
981
996
|
const entry = JSON.parse(line);
|
|
@@ -984,27 +999,12 @@ function createSessionStore(deps) {
|
|
|
984
999
|
} catch { /* skip non-JSON lines */ }
|
|
985
1000
|
}
|
|
986
1001
|
|
|
987
|
-
//
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
const entry = entries.find(e => e && e.sessionId === sessionId);
|
|
994
|
-
if (entry && entry.projectPath) return path.resolve(entry.projectPath) === normCwd;
|
|
995
|
-
const anyPath = (entries.find(e => e && e.projectPath) || {}).projectPath;
|
|
996
|
-
if (anyPath) return path.resolve(anyPath) === normCwd;
|
|
997
|
-
}
|
|
998
|
-
|
|
999
|
-
// Last resort fallback: dir name match (less reliable, skip for worktree paths)
|
|
1000
|
-
// Skip this for paths containing .worktree to avoid edge cases
|
|
1001
|
-
if (normCwd.includes('.worktree')) return true; // trust the session exists
|
|
1002
|
-
const actualDir = path.basename(projectDir).toLowerCase();
|
|
1003
|
-
const expectedDir = process.platform === 'win32'
|
|
1004
|
-
? normCwd.replace(/[:\\\/_ ]/g, '-').toLowerCase()
|
|
1005
|
-
: ('-' + normCwd.replace(/^\//, '').replace(/[\/_. ]/g, '-')).toLowerCase();
|
|
1006
|
-
return actualDir === expectedDir;
|
|
1007
|
-
} catch {
|
|
1002
|
+
// JSONL exists but has no cwd metadata — trust it (e.g., very short session,
|
|
1003
|
+
// or JSONL format changed). Better to attempt resume than force a new session.
|
|
1004
|
+
log('INFO', `[SessionValid] ${sessionId.slice(0, 8)}: no cwd in JSONL, trusting file existence`);
|
|
1005
|
+
return true;
|
|
1006
|
+
} catch (e) {
|
|
1007
|
+
log('WARN', `[SessionValid] ${sessionId.slice(0, 8)}: infra error "${e.message}" — trusting session`);
|
|
1008
1008
|
return true; // conservative: infra failure ≠ invalid session
|
|
1009
1009
|
}
|
|
1010
1010
|
}
|