orchestrix-yuri 3.1.2 → 3.2.0
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/lib/gateway/config.js +2 -0
- package/lib/gateway/engine/phase-orchestrator.js +478 -0
- package/lib/gateway/engine/tmux-utils.js +114 -0
- package/lib/gateway/index.js +9 -0
- package/lib/gateway/router.js +183 -61
- package/package.json +1 -1
- package/skill/resources/start-orchestrix.sh +1 -1
- package/skill/scripts/ensure-session.sh +1 -1
package/lib/gateway/config.js
CHANGED
|
@@ -22,6 +22,8 @@ const DEFAULTS = {
|
|
|
22
22
|
timeout: 300000, // per-message timeout (5 min)
|
|
23
23
|
autocompact_pct: 80, // trigger auto-compact at this % (default 95%)
|
|
24
24
|
compact_every: 50, // proactive /compact after N messages
|
|
25
|
+
phase_poll_interval: 30000, // plan phase: poll agent every 30s
|
|
26
|
+
dev_poll_interval: 300000, // dev phase: poll progress every 5 min
|
|
25
27
|
},
|
|
26
28
|
};
|
|
27
29
|
|
|
@@ -0,0 +1,478 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { execSync } = require('child_process');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const os = require('os');
|
|
7
|
+
const yaml = require('js-yaml');
|
|
8
|
+
const tmx = require('./tmux-utils');
|
|
9
|
+
const { log } = require('../log');
|
|
10
|
+
|
|
11
|
+
const YURI_GLOBAL = path.join(os.homedir(), '.yuri');
|
|
12
|
+
const SKILL_DIR = path.join(os.homedir(), '.claude', 'skills', 'yuri');
|
|
13
|
+
|
|
14
|
+
// ── Plan Agent Sequence ────────────────────────────────────────────────────────
|
|
15
|
+
|
|
16
|
+
const PLAN_AGENTS = [
|
|
17
|
+
{ name: 'analyst', cmd: '*create-doc project-brief', output: 'docs/project-brief.md', window: 0 },
|
|
18
|
+
{ name: 'pm', cmd: '*create-doc prd', output: 'docs/prd.md', window: 1 },
|
|
19
|
+
{ name: 'ux-expert', cmd: '*create-doc front-end-spec', output: 'docs/front-end-spec.md', window: 2 },
|
|
20
|
+
{ name: 'architect', cmd: '*create-doc fullstack-architecture', output: 'docs/architecture.md', window: 3 },
|
|
21
|
+
{ name: 'po', cmd: '*execute-checklist po-master-validation', output: null, window: 4 },
|
|
22
|
+
{ name: 'po', cmd: '*shard', output: null, window: 4, sameWindow: true },
|
|
23
|
+
];
|
|
24
|
+
|
|
25
|
+
// ── Orchestrator ───────────────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
class PhaseOrchestrator {
|
|
28
|
+
/**
|
|
29
|
+
* @param {object} opts
|
|
30
|
+
* @param {function} opts.onProgress - (message: string) → void — proactive Telegram notification
|
|
31
|
+
* @param {function} opts.onComplete - (phase: string, summary: string) → void
|
|
32
|
+
* @param {function} opts.onError - (phase: string, error: string) → void
|
|
33
|
+
* @param {object} opts.config - engine config from channels.yaml
|
|
34
|
+
*/
|
|
35
|
+
constructor(opts = {}) {
|
|
36
|
+
this.onProgress = opts.onProgress || (() => {});
|
|
37
|
+
this.onComplete = opts.onComplete || (() => {});
|
|
38
|
+
this.onError = opts.onError || (() => {});
|
|
39
|
+
this.config = opts.config || {};
|
|
40
|
+
|
|
41
|
+
this._phase = null; // 'plan' | 'develop' | null
|
|
42
|
+
this._step = 0; // current agent index
|
|
43
|
+
this._session = null; // tmux session name
|
|
44
|
+
this._projectRoot = null;
|
|
45
|
+
this._timer = null;
|
|
46
|
+
this._lastHash = '';
|
|
47
|
+
this._stableCount = 0;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
isRunning() { return this._phase !== null; }
|
|
51
|
+
|
|
52
|
+
getStatus() {
|
|
53
|
+
if (!this._phase) {
|
|
54
|
+
return { phase: null, message: 'No phase is running.' };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (this._phase === 'plan') {
|
|
58
|
+
const agent = PLAN_AGENTS[this._step];
|
|
59
|
+
return {
|
|
60
|
+
phase: 'plan',
|
|
61
|
+
step: this._step + 1,
|
|
62
|
+
total: PLAN_AGENTS.length,
|
|
63
|
+
agent: agent ? agent.name : 'unknown',
|
|
64
|
+
message: `📋 Planning: agent ${this._step + 1}/${PLAN_AGENTS.length} (${agent ? agent.name : '?'}) running`,
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (this._phase === 'develop') {
|
|
69
|
+
return {
|
|
70
|
+
phase: 'develop',
|
|
71
|
+
message: '💻 Development in progress. Agents running autonomously.',
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
return { phase: this._phase, message: `Phase ${this._phase} is running.` };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
cancel() {
|
|
79
|
+
if (this._timer) {
|
|
80
|
+
clearInterval(this._timer);
|
|
81
|
+
this._timer = null;
|
|
82
|
+
}
|
|
83
|
+
const phase = this._phase;
|
|
84
|
+
this._phase = null;
|
|
85
|
+
this._step = 0;
|
|
86
|
+
log.engine(`Phase ${phase} cancelled`);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ── Plan Phase ─────────────────────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Start plan phase in background. Returns immediately with status message.
|
|
93
|
+
*/
|
|
94
|
+
startPlan(projectRoot) {
|
|
95
|
+
if (this._phase) {
|
|
96
|
+
return `⚠️ Phase "${this._phase}" is already running. Use *status to check progress.`;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
this._projectRoot = projectRoot;
|
|
100
|
+
this._phase = 'plan';
|
|
101
|
+
this._step = 0;
|
|
102
|
+
this._lastHash = '';
|
|
103
|
+
this._stableCount = 0;
|
|
104
|
+
|
|
105
|
+
// Validate phase1 complete
|
|
106
|
+
const phase1Path = path.join(projectRoot, '.yuri', 'state', 'phase1.yaml');
|
|
107
|
+
if (fs.existsSync(phase1Path)) {
|
|
108
|
+
const phase1 = yaml.load(fs.readFileSync(phase1Path, 'utf8')) || {};
|
|
109
|
+
if (phase1.status !== 'complete') {
|
|
110
|
+
this._phase = null;
|
|
111
|
+
return '❌ Phase 1 (Create) is not complete. Run *create first.';
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Check for resume — find last completed step
|
|
116
|
+
const phase2Path = path.join(projectRoot, '.yuri', 'state', 'phase2.yaml');
|
|
117
|
+
if (fs.existsSync(phase2Path)) {
|
|
118
|
+
const phase2 = yaml.load(fs.readFileSync(phase2Path, 'utf8')) || {};
|
|
119
|
+
if (phase2.status === 'complete') {
|
|
120
|
+
this._phase = null;
|
|
121
|
+
return '✅ Planning already complete. Run *develop to start development.';
|
|
122
|
+
}
|
|
123
|
+
if (phase2.status === 'in_progress' && Array.isArray(phase2.steps)) {
|
|
124
|
+
const lastComplete = phase2.steps.findLastIndex((s) => s.status === 'complete');
|
|
125
|
+
if (lastComplete >= 0) {
|
|
126
|
+
this._step = lastComplete + 1;
|
|
127
|
+
log.engine(`Resuming plan from step ${this._step + 1}/${PLAN_AGENTS.length}`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
// Create/ensure tmux session
|
|
133
|
+
try {
|
|
134
|
+
this._session = this._ensurePlanSession(projectRoot);
|
|
135
|
+
} catch (err) {
|
|
136
|
+
this._phase = null;
|
|
137
|
+
return `❌ Failed to create planning session: ${err.message}`;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Update memory
|
|
141
|
+
this._updatePlanMemory('in_progress');
|
|
142
|
+
|
|
143
|
+
// Start first (or resumed) agent
|
|
144
|
+
try {
|
|
145
|
+
this._startPlanAgent(this._step);
|
|
146
|
+
} catch (err) {
|
|
147
|
+
this._phase = null;
|
|
148
|
+
return `❌ Failed to start agent: ${err.message}`;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Start polling
|
|
152
|
+
const pollInterval = this.config.phase_poll_interval || 30000;
|
|
153
|
+
this._timer = setInterval(() => this._pollPlanAgent(), pollInterval);
|
|
154
|
+
|
|
155
|
+
const agent = PLAN_AGENTS[this._step];
|
|
156
|
+
return `🚀 Planning started! Agent ${this._step + 1}/${PLAN_AGENTS.length} (${agent.name}) is running.\n\nI'll notify you as each agent completes. You can ask me anything in the meantime.`;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Poll current plan agent for completion.
|
|
161
|
+
*/
|
|
162
|
+
_pollPlanAgent() {
|
|
163
|
+
if (this._phase !== 'plan') return;
|
|
164
|
+
|
|
165
|
+
const agent = PLAN_AGENTS[this._step];
|
|
166
|
+
if (!agent) {
|
|
167
|
+
this._completePlan();
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Check if tmux session is alive
|
|
172
|
+
if (!tmx.hasSession(this._session)) {
|
|
173
|
+
this._handleError('plan', 'tmux session died unexpectedly');
|
|
174
|
+
return;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Check completion
|
|
178
|
+
const result = tmx.checkCompletion(this._session, agent.window, this._lastHash);
|
|
179
|
+
|
|
180
|
+
if (result.status === 'complete') {
|
|
181
|
+
this._onAgentComplete(agent);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (result.status === 'stable') {
|
|
186
|
+
this._stableCount++;
|
|
187
|
+
this._lastHash = result.hash;
|
|
188
|
+
if (this._stableCount >= 3) {
|
|
189
|
+
// Content stable for 3 polls — agent likely done
|
|
190
|
+
this._onAgentComplete(agent);
|
|
191
|
+
return;
|
|
192
|
+
}
|
|
193
|
+
} else {
|
|
194
|
+
this._stableCount = 0;
|
|
195
|
+
this._lastHash = result.hash || '';
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
_onAgentComplete(agent) {
|
|
200
|
+
this._stableCount = 0;
|
|
201
|
+
this._lastHash = '';
|
|
202
|
+
|
|
203
|
+
// Verify output file if specified
|
|
204
|
+
let outputExists = true;
|
|
205
|
+
if (agent.output) {
|
|
206
|
+
const outputPath = path.join(this._projectRoot, agent.output);
|
|
207
|
+
outputExists = fs.existsSync(outputPath);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Update phase2 memory
|
|
211
|
+
this._updatePlanStepMemory(this._step, 'complete', agent.output);
|
|
212
|
+
|
|
213
|
+
const stepNum = this._step + 1;
|
|
214
|
+
log.engine(`Plan agent ${stepNum}/${PLAN_AGENTS.length} (${agent.name}) complete`);
|
|
215
|
+
|
|
216
|
+
// Notify user
|
|
217
|
+
const outputStatus = agent.output ? (outputExists ? `→ ${agent.output}` : `⚠️ ${agent.output} not found`) : '';
|
|
218
|
+
this.onProgress(`✅ Agent ${stepNum}/${PLAN_AGENTS.length} (${agent.name}) complete ${outputStatus}`);
|
|
219
|
+
|
|
220
|
+
// Move to next agent
|
|
221
|
+
this._step++;
|
|
222
|
+
|
|
223
|
+
if (this._step >= PLAN_AGENTS.length) {
|
|
224
|
+
this._completePlan();
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Start next agent
|
|
229
|
+
try {
|
|
230
|
+
this._startPlanAgent(this._step);
|
|
231
|
+
} catch (err) {
|
|
232
|
+
this._handleError('plan', `Failed to start next agent: ${err.message}`);
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
_completePlan() {
|
|
237
|
+
if (this._timer) {
|
|
238
|
+
clearInterval(this._timer);
|
|
239
|
+
this._timer = null;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// Kill planning session
|
|
243
|
+
if (this._session && tmx.hasSession(this._session)) {
|
|
244
|
+
tmx.killSession(this._session);
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
// Update memory
|
|
248
|
+
this._updatePlanMemory('complete');
|
|
249
|
+
|
|
250
|
+
this._phase = null;
|
|
251
|
+
this._step = 0;
|
|
252
|
+
|
|
253
|
+
log.engine('Plan phase complete');
|
|
254
|
+
this.onComplete('plan', '🎉 Planning phase complete! All 6 agents finished.\n\nRun *develop to start automated development.');
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
_startPlanAgent(stepIdx) {
|
|
258
|
+
const agent = PLAN_AGENTS[stepIdx];
|
|
259
|
+
if (!agent) return;
|
|
260
|
+
|
|
261
|
+
log.engine(`Starting plan agent ${stepIdx + 1}/${PLAN_AGENTS.length}: ${agent.name} → ${agent.cmd}`);
|
|
262
|
+
|
|
263
|
+
// Create new window unless sameWindow
|
|
264
|
+
if (!agent.sameWindow && stepIdx > 0) {
|
|
265
|
+
tmx.newWindow(this._session, agent.window, agent.name, this._projectRoot);
|
|
266
|
+
// Start Claude Code in the new window
|
|
267
|
+
tmx.sendKeys(this._session, agent.window, 'cc');
|
|
268
|
+
execSync('sleep 1');
|
|
269
|
+
execSync(`tmux send-keys -t "${this._session}:${agent.window}" C-m`);
|
|
270
|
+
|
|
271
|
+
// Wait for Claude Code to start (with trust dialog handling)
|
|
272
|
+
const ready = tmx.waitForPrompt(this._session, agent.window, 30000);
|
|
273
|
+
if (!ready) {
|
|
274
|
+
throw new Error(`Claude Code did not start in window ${agent.window}`);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Activate Orchestrix agent
|
|
279
|
+
tmx.sendKeysWithEnter(this._session, agent.window, `/o ${agent.name}`);
|
|
280
|
+
execSync('sleep 10'); // Wait for agent to load
|
|
281
|
+
|
|
282
|
+
// Send command
|
|
283
|
+
tmx.sendKeysWithEnter(this._session, agent.window, agent.cmd);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
_ensurePlanSession(projectRoot) {
|
|
287
|
+
const scriptPath = path.join(SKILL_DIR, 'scripts', 'ensure-session.sh');
|
|
288
|
+
if (!fs.existsSync(scriptPath)) {
|
|
289
|
+
throw new Error(`ensure-session.sh not found at ${scriptPath}`);
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const result = execSync(`bash "${scriptPath}" planning "${projectRoot}"`, {
|
|
293
|
+
encoding: 'utf8',
|
|
294
|
+
timeout: 60000,
|
|
295
|
+
}).trim();
|
|
296
|
+
|
|
297
|
+
// ensure-session.sh echoes the session name
|
|
298
|
+
const lines = result.split('\n');
|
|
299
|
+
return lines[lines.length - 1].trim();
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// ── Develop Phase ──────────────────────────────────────────────────────────
|
|
303
|
+
|
|
304
|
+
startDevelop(projectRoot) {
|
|
305
|
+
if (this._phase) {
|
|
306
|
+
return `⚠️ Phase "${this._phase}" is already running. Use *status to check progress.`;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
this._projectRoot = projectRoot;
|
|
310
|
+
this._phase = 'develop';
|
|
311
|
+
|
|
312
|
+
// Validate phase2 complete
|
|
313
|
+
const phase2Path = path.join(projectRoot, '.yuri', 'state', 'phase2.yaml');
|
|
314
|
+
if (fs.existsSync(phase2Path)) {
|
|
315
|
+
const phase2 = yaml.load(fs.readFileSync(phase2Path, 'utf8')) || {};
|
|
316
|
+
if (phase2.status !== 'complete') {
|
|
317
|
+
this._phase = null;
|
|
318
|
+
return '❌ Phase 2 (Plan) is not complete. Run *plan first.';
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// Start dev session via ensure-session.sh (runs start-orchestrix.sh)
|
|
323
|
+
try {
|
|
324
|
+
const scriptPath = path.join(SKILL_DIR, 'scripts', 'ensure-session.sh');
|
|
325
|
+
const result = execSync(`bash "${scriptPath}" dev "${projectRoot}"`, {
|
|
326
|
+
encoding: 'utf8',
|
|
327
|
+
timeout: 120000, // dev session setup takes longer (start-orchestrix.sh)
|
|
328
|
+
}).trim();
|
|
329
|
+
const lines = result.split('\n');
|
|
330
|
+
this._session = lines[lines.length - 1].trim();
|
|
331
|
+
} catch (err) {
|
|
332
|
+
this._phase = null;
|
|
333
|
+
return `❌ Failed to start dev session: ${err.message}`;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
// Start polling (less frequent — handoff-detector handles agent chaining)
|
|
337
|
+
const pollInterval = this.config.dev_poll_interval || 300000; // 5 min
|
|
338
|
+
this._timer = setInterval(() => this._pollDevSession(), pollInterval);
|
|
339
|
+
|
|
340
|
+
log.engine(`Dev phase started: session=${this._session}`);
|
|
341
|
+
return '🚀 Development started! 4 agents (Architect, SM, Dev, QA) are running.\n\nAgents chain automatically via handoff-detector. I\'ll report progress every 5 minutes.';
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
_pollDevSession() {
|
|
345
|
+
if (this._phase !== 'develop') return;
|
|
346
|
+
|
|
347
|
+
if (!tmx.hasSession(this._session)) {
|
|
348
|
+
this._handleError('develop', 'Dev tmux session died unexpectedly');
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// Read story progress from scan-stories.sh or phase3.yaml
|
|
353
|
+
const phase3Path = path.join(this._projectRoot, '.yuri', 'state', 'phase3.yaml');
|
|
354
|
+
if (fs.existsSync(phase3Path)) {
|
|
355
|
+
try {
|
|
356
|
+
const phase3 = yaml.load(fs.readFileSync(phase3Path, 'utf8')) || {};
|
|
357
|
+
const progress = phase3.progress || {};
|
|
358
|
+
const byStatus = progress.by_status || {};
|
|
359
|
+
const total = progress.total_stories || 0;
|
|
360
|
+
const done = (byStatus.done || 0) + (byStatus.complete || 0);
|
|
361
|
+
|
|
362
|
+
if (total > 0 && done >= total) {
|
|
363
|
+
this._completeDev();
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Report progress
|
|
368
|
+
this.onProgress(`💻 Dev progress: ${done}/${total} stories complete`);
|
|
369
|
+
} catch { /* continue polling */ }
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
_completeDev() {
|
|
374
|
+
if (this._timer) {
|
|
375
|
+
clearInterval(this._timer);
|
|
376
|
+
this._timer = null;
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
this._phase = null;
|
|
380
|
+
log.engine('Dev phase complete');
|
|
381
|
+
this.onComplete('develop', '🎉 Development complete! All stories finished.\n\nRun *test to start smoke testing.');
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// ── Shared ─────────────────────────────────────────────────────────────────
|
|
385
|
+
|
|
386
|
+
_handleError(phase, message) {
|
|
387
|
+
if (this._timer) {
|
|
388
|
+
clearInterval(this._timer);
|
|
389
|
+
this._timer = null;
|
|
390
|
+
}
|
|
391
|
+
this._phase = null;
|
|
392
|
+
log.error(`Phase ${phase} error: ${message}`);
|
|
393
|
+
this.onError(phase, message);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
_updatePlanMemory(status) {
|
|
397
|
+
const projectRoot = this._projectRoot;
|
|
398
|
+
const yuriDir = path.join(projectRoot, '.yuri');
|
|
399
|
+
|
|
400
|
+
// Update phase2.yaml
|
|
401
|
+
const phase2Path = path.join(yuriDir, 'state', 'phase2.yaml');
|
|
402
|
+
const stateDir = path.join(yuriDir, 'state');
|
|
403
|
+
if (!fs.existsSync(stateDir)) fs.mkdirSync(stateDir, { recursive: true });
|
|
404
|
+
|
|
405
|
+
let phase2 = {};
|
|
406
|
+
if (fs.existsSync(phase2Path)) {
|
|
407
|
+
phase2 = yaml.load(fs.readFileSync(phase2Path, 'utf8')) || {};
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
phase2.status = status;
|
|
411
|
+
if (status === 'in_progress' && !phase2.started_at) {
|
|
412
|
+
phase2.started_at = new Date().toISOString();
|
|
413
|
+
}
|
|
414
|
+
if (status === 'complete') {
|
|
415
|
+
phase2.completed_at = new Date().toISOString();
|
|
416
|
+
}
|
|
417
|
+
if (!Array.isArray(phase2.steps)) {
|
|
418
|
+
phase2.steps = PLAN_AGENTS.map((a) => ({ id: a.name, status: 'pending' }));
|
|
419
|
+
}
|
|
420
|
+
if (this._session) {
|
|
421
|
+
phase2.tmux = { session: this._session };
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
fs.writeFileSync(phase2Path, yaml.dump(phase2, { lineWidth: -1 }));
|
|
425
|
+
|
|
426
|
+
// Update focus.yaml
|
|
427
|
+
const focusPath = path.join(yuriDir, 'focus.yaml');
|
|
428
|
+
let focus = {};
|
|
429
|
+
if (fs.existsSync(focusPath)) {
|
|
430
|
+
focus = yaml.load(fs.readFileSync(focusPath, 'utf8')) || {};
|
|
431
|
+
}
|
|
432
|
+
focus.phase = 2;
|
|
433
|
+
focus.step = status === 'complete' ? 'phase2.complete' : 'planning';
|
|
434
|
+
focus.pulse = status === 'complete' ? 'Phase 2 complete' : `Phase 2: ${this._step + 1}/${PLAN_AGENTS.length} agents`;
|
|
435
|
+
focus.updated_at = new Date().toISOString();
|
|
436
|
+
fs.writeFileSync(focusPath, yaml.dump(focus, { lineWidth: -1 }));
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
_updatePlanStepMemory(stepIdx, status, output) {
|
|
440
|
+
const phase2Path = path.join(this._projectRoot, '.yuri', 'state', 'phase2.yaml');
|
|
441
|
+
if (!fs.existsSync(phase2Path)) return;
|
|
442
|
+
|
|
443
|
+
const phase2 = yaml.load(fs.readFileSync(phase2Path, 'utf8')) || {};
|
|
444
|
+
if (Array.isArray(phase2.steps) && phase2.steps[stepIdx]) {
|
|
445
|
+
phase2.steps[stepIdx].status = status;
|
|
446
|
+
if (output) phase2.steps[stepIdx].output = output;
|
|
447
|
+
phase2.steps[stepIdx].completed_at = new Date().toISOString();
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
fs.writeFileSync(phase2Path, yaml.dump(phase2, { lineWidth: -1 }));
|
|
451
|
+
|
|
452
|
+
// Append timeline event
|
|
453
|
+
const timelinePath = path.join(this._projectRoot, '.yuri', 'timeline', 'events.jsonl');
|
|
454
|
+
const timelineDir = path.dirname(timelinePath);
|
|
455
|
+
if (!fs.existsSync(timelineDir)) fs.mkdirSync(timelineDir, { recursive: true });
|
|
456
|
+
|
|
457
|
+
const event = {
|
|
458
|
+
ts: new Date().toISOString(),
|
|
459
|
+
type: 'agent_completed',
|
|
460
|
+
agent: PLAN_AGENTS[stepIdx].name,
|
|
461
|
+
output: output || '',
|
|
462
|
+
};
|
|
463
|
+
fs.appendFileSync(timelinePath, JSON.stringify(event) + '\n');
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* Graceful shutdown — stop polling but don't kill tmux sessions.
|
|
468
|
+
*/
|
|
469
|
+
shutdown() {
|
|
470
|
+
if (this._timer) {
|
|
471
|
+
clearInterval(this._timer);
|
|
472
|
+
this._timer = null;
|
|
473
|
+
}
|
|
474
|
+
log.engine('Phase orchestrator shut down (tmux sessions preserved)');
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
module.exports = { PhaseOrchestrator };
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { execSync } = require('child_process');
|
|
4
|
+
const crypto = require('crypto');
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Shared tmux utilities for phase orchestration.
|
|
8
|
+
* All operations are synchronous (execSync) since tmux commands are instant.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
function tmux(cmd) {
|
|
12
|
+
return execSync(`tmux ${cmd}`, { encoding: 'utf8', timeout: 10000 }).trim();
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function tmuxSafe(cmd) {
|
|
16
|
+
try { return tmux(cmd); } catch { return null; }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function hasSession(session) {
|
|
20
|
+
return tmuxSafe(`has-session -t "${session}" 2>/dev/null`) !== null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function killSession(session) {
|
|
24
|
+
tmuxSafe(`kill-session -t "${session}"`);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function capturePane(session, window, lines) {
|
|
28
|
+
return tmuxSafe(`capture-pane -t "${session}:${window}" -p -S -${lines || 200}`) || '';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function sendKeys(session, window, text) {
|
|
32
|
+
tmux(`send-keys -t "${session}:${window}" "${text.replace(/"/g, '\\"')}"`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Send text to a tmux pane with proper Enter handling.
|
|
37
|
+
* 3-step pattern: send content → sleep 1s → send Enter.
|
|
38
|
+
*/
|
|
39
|
+
function sendKeysWithEnter(session, window, text) {
|
|
40
|
+
sendKeys(session, window, text);
|
|
41
|
+
execSync('sleep 1');
|
|
42
|
+
tmux(`send-keys -t "${session}:${window}" Enter`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function newWindow(session, windowIdx, name, cwd) {
|
|
46
|
+
tmux(`new-window -t "${session}:${windowIdx}" -n "${name}" -c "${cwd}"`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Wait for Claude Code's ❯ prompt to appear in pane.
|
|
51
|
+
* Also auto-accepts "trust this folder" dialog if detected.
|
|
52
|
+
*
|
|
53
|
+
* @returns {boolean} true if prompt appeared, false if timeout
|
|
54
|
+
*/
|
|
55
|
+
function waitForPrompt(session, window, timeoutMs) {
|
|
56
|
+
const deadline = Date.now() + (timeoutMs || 30000);
|
|
57
|
+
const pollMs = 2000;
|
|
58
|
+
|
|
59
|
+
while (Date.now() < deadline) {
|
|
60
|
+
execSync(`sleep ${pollMs / 1000}`);
|
|
61
|
+
const pane = capturePane(session, window, 15);
|
|
62
|
+
|
|
63
|
+
// Auto-accept trust dialog
|
|
64
|
+
if (/trust this folder|safety check/i.test(pane)) {
|
|
65
|
+
tmux(`send-keys -t "${session}:${window}" Enter`);
|
|
66
|
+
execSync('sleep 2');
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ❯ prompt means Claude Code is ready
|
|
71
|
+
if (/❯/.test(pane)) {
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Check if a Claude Code agent has finished in a tmux pane.
|
|
80
|
+
* Mirrors the logic from monitor-agent.sh.
|
|
81
|
+
*
|
|
82
|
+
* @returns {'complete'|'idle'|'running'|'stable'}
|
|
83
|
+
*/
|
|
84
|
+
function checkCompletion(session, window, lastHash) {
|
|
85
|
+
const pane = capturePane(session, window, 200);
|
|
86
|
+
const tail = pane.split('\n').slice(-10).join('\n');
|
|
87
|
+
|
|
88
|
+
// P1: Completion message ("Baked for 31s", "Worked for 2m")
|
|
89
|
+
if (/[A-Z][a-z]*ed for \d+/.test(tail)) {
|
|
90
|
+
return { status: 'complete', hash: null };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// P2: ❯ prompt with no active spinner — might be idle
|
|
94
|
+
// (Less reliable than completion message but still useful)
|
|
95
|
+
|
|
96
|
+
// P3: Content stability
|
|
97
|
+
const hash = crypto.createHash('md5').update(pane).digest('hex');
|
|
98
|
+
if (hash === lastHash) {
|
|
99
|
+
return { status: 'stable', hash };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return { status: 'running', hash };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
module.exports = {
|
|
106
|
+
hasSession,
|
|
107
|
+
killSession,
|
|
108
|
+
capturePane,
|
|
109
|
+
sendKeys,
|
|
110
|
+
sendKeysWithEnter,
|
|
111
|
+
newWindow,
|
|
112
|
+
waitForPrompt,
|
|
113
|
+
checkCompletion,
|
|
114
|
+
};
|
package/lib/gateway/index.js
CHANGED
|
@@ -39,6 +39,15 @@ async function startGateway(opts = {}) {
|
|
|
39
39
|
});
|
|
40
40
|
adapters.push(telegram);
|
|
41
41
|
|
|
42
|
+
// Wire proactive messaging: orchestrator → Telegram
|
|
43
|
+
router.setSendCallback(null, async (chatId, text) => {
|
|
44
|
+
try {
|
|
45
|
+
await telegram.bot.api.sendMessage(chatId, text, { parse_mode: 'Markdown' });
|
|
46
|
+
} catch {
|
|
47
|
+
await telegram.bot.api.sendMessage(chatId, text).catch(() => {});
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
|
|
42
51
|
try {
|
|
43
52
|
await telegram.start();
|
|
44
53
|
} catch (err) {
|
package/lib/gateway/router.js
CHANGED
|
@@ -9,18 +9,31 @@ const { ChatHistory } = require('./history');
|
|
|
9
9
|
const { OwnerBinding } = require('./binding');
|
|
10
10
|
const engine = require('./engine/claude-sdk');
|
|
11
11
|
const { runReflect } = require('./engine/reflect');
|
|
12
|
+
const { PhaseOrchestrator } = require('./engine/phase-orchestrator');
|
|
12
13
|
const { log } = require('./log');
|
|
13
14
|
|
|
14
15
|
const YURI_GLOBAL = path.join(os.homedir(), '.yuri');
|
|
15
16
|
|
|
17
|
+
// ── Phase command patterns ─────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
const PHASE_COMMANDS = {
|
|
20
|
+
plan: /^\*plan\b/i,
|
|
21
|
+
develop: /^\*develop\b/i,
|
|
22
|
+
test: /^\*test\b/i,
|
|
23
|
+
deploy: /^\*deploy\b/i,
|
|
24
|
+
cancel: /^\*cancel\b/i,
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const STATUS_PATTERNS = [
|
|
28
|
+
/^\*status\b/i,
|
|
29
|
+
/进度|状态|怎么样了|到哪了/,
|
|
30
|
+
/\bstatus\b|\bprogress\b/i,
|
|
31
|
+
];
|
|
32
|
+
|
|
16
33
|
/**
|
|
17
|
-
* Message router with five-engine orchestration.
|
|
18
|
-
* Each engine is triggered by code logic, not prompt compliance.
|
|
34
|
+
* Message router with five-engine orchestration + async phase execution.
|
|
19
35
|
*/
|
|
20
36
|
class Router {
|
|
21
|
-
/**
|
|
22
|
-
* @param {object} config - Parsed channels.yaml config
|
|
23
|
-
*/
|
|
24
37
|
constructor(config) {
|
|
25
38
|
this.config = config;
|
|
26
39
|
this.history = new ChatHistory({
|
|
@@ -31,41 +44,85 @@ class Router {
|
|
|
31
44
|
telegram: new OwnerBinding({ channelType: 'telegram' }),
|
|
32
45
|
feishu: new OwnerBinding({ channelType: 'feishu' }),
|
|
33
46
|
};
|
|
34
|
-
this.processing = new Set();
|
|
47
|
+
this.processing = new Set();
|
|
48
|
+
this._ownerChatId = null;
|
|
49
|
+
this._sendCallback = null;
|
|
50
|
+
|
|
51
|
+
// Phase orchestrator — runs long operations in background
|
|
52
|
+
this.orchestrator = new PhaseOrchestrator({
|
|
53
|
+
config: config.engine,
|
|
54
|
+
onProgress: (msg) => this._sendProactive(msg),
|
|
55
|
+
onComplete: (phase, summary) => this._sendProactive(summary),
|
|
56
|
+
onError: (phase, err) => this._sendProactive(`❌ Phase ${phase} error: ${err}`),
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Set the callback for proactive Telegram messages.
|
|
62
|
+
* Called by index.js after the Telegram adapter starts.
|
|
63
|
+
*/
|
|
64
|
+
setSendCallback(chatId, callback) {
|
|
65
|
+
this._ownerChatId = chatId;
|
|
66
|
+
this._sendCallback = callback;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
_sendProactive(text) {
|
|
70
|
+
if (this._sendCallback && this._ownerChatId) {
|
|
71
|
+
this._sendCallback(this._ownerChatId, text).catch((err) => {
|
|
72
|
+
log.warn(`Proactive send failed: ${err.message}`);
|
|
73
|
+
});
|
|
74
|
+
}
|
|
35
75
|
}
|
|
36
76
|
|
|
37
77
|
/**
|
|
38
|
-
* Handle an incoming channel message.
|
|
39
|
-
* All five engines are orchestrated here via code.
|
|
40
|
-
*
|
|
41
|
-
* @param {object} msg - {channelType, channelUserId, chatId, text, userName}
|
|
42
|
-
* @returns {Promise<{text: string}>}
|
|
78
|
+
* Handle an incoming channel message.
|
|
43
79
|
*/
|
|
44
80
|
async handleMessage(msg) {
|
|
45
81
|
// ═══ AUTH ═══
|
|
46
82
|
const binding = this.bindings[msg.channelType];
|
|
47
|
-
if (!binding) {
|
|
48
|
-
return { text: '❌ Unsupported channel type.' };
|
|
49
|
-
}
|
|
83
|
+
if (!binding) return { text: '❌ Unsupported channel type.' };
|
|
50
84
|
|
|
51
85
|
const authResult = binding.check(msg.chatId);
|
|
52
|
-
if (!authResult.allowed) {
|
|
53
|
-
return { text: '🔒 Unauthorized. This bot is private.' };
|
|
54
|
-
}
|
|
86
|
+
if (!authResult.allowed) return { text: '🔒 Unauthorized. This bot is private.' };
|
|
55
87
|
|
|
56
88
|
if (authResult.firstBind) {
|
|
57
89
|
log.router(`First bind: ${msg.channelType} chat ${msg.chatId} (${msg.userName})`);
|
|
58
90
|
}
|
|
59
91
|
|
|
60
|
-
//
|
|
92
|
+
// Store owner chatId for proactive messaging
|
|
93
|
+
if (!this._ownerChatId) {
|
|
94
|
+
this._ownerChatId = msg.chatId;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// Handle /start
|
|
61
98
|
if (msg.text === '/start') {
|
|
62
99
|
if (authResult.firstBind) {
|
|
63
|
-
return { text:
|
|
100
|
+
return { text: '🚀 Welcome! You are now bound as the owner of this Yuri instance.\n\nSend me any message to interact with your projects.' };
|
|
101
|
+
}
|
|
102
|
+
return { text: '🚀 Yuri is ready. Send me any message to interact with your projects.' };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ═══ STATUS QUERY — always allowed, even during processing ═══
|
|
106
|
+
if (this._isStatusQuery(msg.text)) {
|
|
107
|
+
return this._handleStatusQuery(msg);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// ═══ CANCEL — stop running phase ═══
|
|
111
|
+
if (PHASE_COMMANDS.cancel.test(msg.text.trim())) {
|
|
112
|
+
if (this.orchestrator.isRunning()) {
|
|
113
|
+
this.orchestrator.cancel();
|
|
114
|
+
return { text: '🛑 Phase cancelled.' };
|
|
64
115
|
}
|
|
65
|
-
return { text:
|
|
116
|
+
return { text: 'No phase is running.' };
|
|
66
117
|
}
|
|
67
118
|
|
|
68
|
-
//
|
|
119
|
+
// ═══ PHASE COMMANDS — delegate to orchestrator (non-blocking) ═══
|
|
120
|
+
const phaseCmd = this._detectPhaseCommand(msg.text);
|
|
121
|
+
if (phaseCmd) {
|
|
122
|
+
return this._handlePhaseCommand(phaseCmd, msg);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ═══ NORMAL MESSAGE — goes through Claude ═══
|
|
69
126
|
if (this.processing.has(msg.chatId)) {
|
|
70
127
|
return { text: '⏳ Still processing your previous message. Please wait.' };
|
|
71
128
|
}
|
|
@@ -78,22 +135,96 @@ class Router {
|
|
|
78
135
|
}
|
|
79
136
|
}
|
|
80
137
|
|
|
138
|
+
// ── Phase Command Handling ───────────────────────────────────────────────────
|
|
139
|
+
|
|
140
|
+
_detectPhaseCommand(text) {
|
|
141
|
+
const trimmed = text.trim();
|
|
142
|
+
for (const [phase, re] of Object.entries(PHASE_COMMANDS)) {
|
|
143
|
+
if (phase === 'cancel') continue; // handled separately
|
|
144
|
+
if (re.test(trimmed)) return phase;
|
|
145
|
+
}
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
_handlePhaseCommand(phase, msg) {
|
|
150
|
+
const projectRoot = engine.resolveProjectRoot();
|
|
151
|
+
if (!projectRoot) {
|
|
152
|
+
return { text: '❌ No active project found. Create one first with *create.' };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
let response;
|
|
156
|
+
switch (phase) {
|
|
157
|
+
case 'plan':
|
|
158
|
+
response = this.orchestrator.startPlan(projectRoot);
|
|
159
|
+
break;
|
|
160
|
+
case 'develop':
|
|
161
|
+
response = this.orchestrator.startDevelop(projectRoot);
|
|
162
|
+
break;
|
|
163
|
+
case 'test':
|
|
164
|
+
case 'deploy':
|
|
165
|
+
// These phases are simpler — let Claude handle them normally
|
|
166
|
+
// (they don't have the 30-minute orchestration problem)
|
|
167
|
+
return this._processMessageDirect(msg);
|
|
168
|
+
default:
|
|
169
|
+
response = `Unknown phase: ${phase}`;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// Save to chat history
|
|
173
|
+
this.history.append(msg.chatId, 'user', msg.text);
|
|
174
|
+
this.history.append(msg.chatId, 'assistant', response.slice(0, 2000));
|
|
175
|
+
this._updateGlobalFocus(msg, projectRoot);
|
|
176
|
+
|
|
177
|
+
return { text: response };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
// ── Status Query ─────────────────────────────────────────────────────────────
|
|
181
|
+
|
|
182
|
+
_isStatusQuery(text) {
|
|
183
|
+
return STATUS_PATTERNS.some((re) => re.test(text.trim()));
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
_handleStatusQuery(msg) {
|
|
187
|
+
const parts = [];
|
|
188
|
+
|
|
189
|
+
// Orchestrator status
|
|
190
|
+
if (this.orchestrator.isRunning()) {
|
|
191
|
+
const status = this.orchestrator.getStatus();
|
|
192
|
+
parts.push(status.message);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Read project focus for additional context
|
|
196
|
+
const projectRoot = engine.resolveProjectRoot();
|
|
197
|
+
if (projectRoot) {
|
|
198
|
+
const focusPath = path.join(projectRoot, '.yuri', 'focus.yaml');
|
|
199
|
+
if (fs.existsSync(focusPath)) {
|
|
200
|
+
try {
|
|
201
|
+
const focus = yaml.load(fs.readFileSync(focusPath, 'utf8')) || {};
|
|
202
|
+
if (focus.pulse) parts.push(`Pulse: ${focus.pulse}`);
|
|
203
|
+
if (focus.step) parts.push(`Step: ${focus.step}`);
|
|
204
|
+
} catch { /* ok */ }
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (parts.length === 0) {
|
|
209
|
+
parts.push('No active phase. Available commands: *create, *plan, *develop, *test, *deploy');
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
this.history.append(msg.chatId, 'user', msg.text);
|
|
213
|
+
const reply = parts.join('\n');
|
|
214
|
+
this.history.append(msg.chatId, 'assistant', reply);
|
|
215
|
+
|
|
216
|
+
return { text: reply };
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ── Normal Message Processing (via Claude) ───────────────────────────────────
|
|
220
|
+
|
|
81
221
|
async _processMessage(msg) {
|
|
82
|
-
// ═══ ENGINE: Reflect (code-enforced) ═══
|
|
83
|
-
// Process any unprocessed inbox signals BEFORE the Claude call,
|
|
84
|
-
// so the updated memory is available in the system prompt.
|
|
85
222
|
try { runReflect(); } catch (err) { log.warn(`Reflect failed: ${err.message}`); }
|
|
86
|
-
|
|
87
|
-
// ═══ ENGINE: Catch-up (code-enforced) ═══
|
|
88
223
|
await this._runCatchUp();
|
|
89
224
|
|
|
90
|
-
// ═══ Resolve project context ═══
|
|
91
225
|
const projectRoot = engine.resolveProjectRoot();
|
|
92
|
-
|
|
93
|
-
// ═══ Compose prompt ═══
|
|
94
226
|
const prompt = engine.composePrompt(msg.text);
|
|
95
227
|
|
|
96
|
-
// ═══ WORK: Call Claude engine ═══
|
|
97
228
|
log.router(`Processing: "${msg.text.slice(0, 80)}..." → cwd: ${projectRoot || '~'}`);
|
|
98
229
|
const result = await engine.callClaude({
|
|
99
230
|
prompt,
|
|
@@ -101,15 +232,9 @@ class Router {
|
|
|
101
232
|
engineConfig: this.config.engine,
|
|
102
233
|
});
|
|
103
234
|
|
|
104
|
-
// ═══ Save chat history ═══
|
|
105
235
|
this.history.append(msg.chatId, 'user', msg.text);
|
|
106
236
|
this.history.append(msg.chatId, 'assistant', result.reply.slice(0, 2000));
|
|
107
|
-
|
|
108
|
-
// ═══ ENGINE: Observe (code-enforced signal detection) ═══
|
|
109
|
-
// Detect signals from BOTH user message and Claude's response.
|
|
110
237
|
this._detectSignals(msg, result.reply);
|
|
111
|
-
|
|
112
|
-
// ═══ ENGINE: Update Focus (code-enforced) ═══
|
|
113
238
|
this._updateGlobalFocus(msg, projectRoot);
|
|
114
239
|
|
|
115
240
|
log.router(`Reply: "${result.reply.slice(0, 80)}..."`);
|
|
@@ -117,8 +242,23 @@ class Router {
|
|
|
117
242
|
}
|
|
118
243
|
|
|
119
244
|
/**
|
|
120
|
-
*
|
|
245
|
+
* Process a message through Claude without the processing guard.
|
|
246
|
+
* Used for phase commands that should be handled by Claude (test, deploy).
|
|
121
247
|
*/
|
|
248
|
+
async _processMessageDirect(msg) {
|
|
249
|
+
if (this.processing.has(msg.chatId)) {
|
|
250
|
+
return { text: '⏳ Still processing your previous message. Please wait.' };
|
|
251
|
+
}
|
|
252
|
+
this.processing.add(msg.chatId);
|
|
253
|
+
try {
|
|
254
|
+
return await this._processMessage(msg);
|
|
255
|
+
} finally {
|
|
256
|
+
this.processing.delete(msg.chatId);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// ── Catch-up ─────────────────────────────────────────────────────────────────
|
|
261
|
+
|
|
122
262
|
async _runCatchUp() {
|
|
123
263
|
const focusPath = path.join(YURI_GLOBAL, 'focus.yaml');
|
|
124
264
|
if (!fs.existsSync(focusPath)) return;
|
|
@@ -127,17 +267,12 @@ class Router {
|
|
|
127
267
|
if (!focus.updated_at) return;
|
|
128
268
|
|
|
129
269
|
const gap = Date.now() - new Date(focus.updated_at).getTime();
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
if (gap > ONE_HOUR) {
|
|
133
|
-
log.router(`Catch-up: ${Math.round(gap / 60000)}min since last active. Refreshing portfolio.`);
|
|
270
|
+
if (gap > 3600_000) {
|
|
271
|
+
log.router(`Catch-up: ${Math.round(gap / 60000)}min idle. Refreshing portfolio.`);
|
|
134
272
|
this._refreshPortfolioPulse();
|
|
135
273
|
}
|
|
136
274
|
}
|
|
137
275
|
|
|
138
|
-
/**
|
|
139
|
-
* Refresh portfolio pulse by scanning active projects.
|
|
140
|
-
*/
|
|
141
276
|
_refreshPortfolioPulse() {
|
|
142
277
|
const registryPath = path.join(YURI_GLOBAL, 'portfolio', 'registry.yaml');
|
|
143
278
|
if (!fs.existsSync(registryPath)) return;
|
|
@@ -167,7 +302,7 @@ class Router {
|
|
|
167
302
|
}
|
|
168
303
|
}
|
|
169
304
|
|
|
170
|
-
// ── Signal Detection
|
|
305
|
+
// ── Signal Detection ─────────────────────────────────────────────────────────
|
|
171
306
|
|
|
172
307
|
static PRIORITY_PATTERNS = [
|
|
173
308
|
/\b(focus\s+on|switch\s+to|prioritize)\b/i,
|
|
@@ -200,17 +335,11 @@ class Router {
|
|
|
200
335
|
/你的角色|你提到/,
|
|
201
336
|
];
|
|
202
337
|
|
|
203
|
-
/**
|
|
204
|
-
* Detect signals from user message AND Claude's response.
|
|
205
|
-
* Uses word-boundary regex to avoid false positives.
|
|
206
|
-
*/
|
|
207
338
|
_detectSignals(msg, claudeReply) {
|
|
208
339
|
const inboxPath = path.join(YURI_GLOBAL, 'inbox.jsonl');
|
|
209
340
|
const signals = [];
|
|
210
|
-
|
|
211
341
|
const text = msg.text;
|
|
212
342
|
|
|
213
|
-
// Detect from user message
|
|
214
343
|
if (Router.PRIORITY_PATTERNS.some((re) => re.test(text))) {
|
|
215
344
|
signals.push({ signal: 'priority_change', raw: text });
|
|
216
345
|
}
|
|
@@ -221,10 +350,8 @@ class Router {
|
|
|
221
350
|
signals.push({ signal: 'boss_identity', raw: text });
|
|
222
351
|
}
|
|
223
352
|
|
|
224
|
-
// Detect from Claude's response (confirms Claude recognized a signal)
|
|
225
353
|
if (claudeReply) {
|
|
226
354
|
if (Router.RESPONSE_PREFERENCE_HINTS.some((re) => re.test(claudeReply))) {
|
|
227
|
-
// Only add if we didn't already detect from user message
|
|
228
355
|
if (!signals.some((s) => s.signal === 'boss_preference')) {
|
|
229
356
|
signals.push({ signal: 'boss_preference', raw: text });
|
|
230
357
|
}
|
|
@@ -236,7 +363,6 @@ class Router {
|
|
|
236
363
|
}
|
|
237
364
|
}
|
|
238
365
|
|
|
239
|
-
// Write to inbox
|
|
240
366
|
for (const sig of signals) {
|
|
241
367
|
const entry = {
|
|
242
368
|
ts: new Date().toISOString(),
|
|
@@ -253,9 +379,6 @@ class Router {
|
|
|
253
379
|
}
|
|
254
380
|
}
|
|
255
381
|
|
|
256
|
-
/**
|
|
257
|
-
* Update global focus after processing a message.
|
|
258
|
-
*/
|
|
259
382
|
_updateGlobalFocus(msg, projectRoot) {
|
|
260
383
|
const focusPath = path.join(YURI_GLOBAL, 'focus.yaml');
|
|
261
384
|
if (!fs.existsSync(focusPath)) return;
|
|
@@ -271,12 +394,11 @@ class Router {
|
|
|
271
394
|
}
|
|
272
395
|
|
|
273
396
|
/**
|
|
274
|
-
* Graceful shutdown
|
|
397
|
+
* Graceful shutdown.
|
|
275
398
|
*/
|
|
276
399
|
async shutdown() {
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
}
|
|
400
|
+
this.orchestrator.shutdown();
|
|
401
|
+
if (engine.destroySession) engine.destroySession();
|
|
280
402
|
}
|
|
281
403
|
}
|
|
282
404
|
|
package/package.json
CHANGED
|
@@ -303,7 +303,7 @@ for i in $(seq "$CC_STARTUP_WAIT" -1 1); do
|
|
|
303
303
|
if [ $((i % 2)) -eq 0 ] && [ -z "$TRUST_HANDLED" ]; then
|
|
304
304
|
for w in 0 1 2 3; do
|
|
305
305
|
PANE=$(tmux capture-pane -t "$SESSION_NAME:$w" -p -S -10 2>/dev/null || true)
|
|
306
|
-
if echo "$PANE" | grep -qi "trust"; then
|
|
306
|
+
if echo "$PANE" | grep -qi "trust this folder\|safety check"; then
|
|
307
307
|
tmux send-keys -t "$SESSION_NAME:$w" Enter
|
|
308
308
|
echo ""
|
|
309
309
|
echo " 🔓 Auto-accepted trust dialog in window $w"
|
|
@@ -33,7 +33,7 @@ if [ "$TYPE" = "planning" ]; then
|
|
|
33
33
|
for i in $(seq 1 6); do
|
|
34
34
|
sleep 2
|
|
35
35
|
PANE_TEXT=$(tmux capture-pane -t "$SESSION:0" -p -S -10 2>/dev/null || true)
|
|
36
|
-
if echo "$PANE_TEXT" | grep -qi "trust"; then
|
|
36
|
+
if echo "$PANE_TEXT" | grep -qi "trust this folder\|safety check"; then
|
|
37
37
|
tmux send-keys -t "$SESSION:0" Enter
|
|
38
38
|
sleep 2
|
|
39
39
|
break
|