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.
@@ -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
+ };
@@ -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) {
@@ -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(); // prevent concurrent processing per chat
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. This is the main entry point.
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
- // Handle /start command
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: `🚀 Welcome! You are now bound as the owner of this Yuri instance.\n\nSend me any message to interact with your projects.` };
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: `🚀 Yuri is ready. Send me any message to interact with your projects.` };
116
+ return { text: 'No phase is running.' };
66
117
  }
67
118
 
68
- // Prevent concurrent processing for same chat
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
- * ENGINE: Catch-up check if Yuri has been idle and needs to refresh state.
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
- const ONE_HOUR = 3600_000;
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 Patterns (word-boundary aware) ──
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 — destroy persistent engine session if active.
397
+ * Graceful shutdown.
275
398
  */
276
399
  async shutdown() {
277
- if (engine.destroySession) {
278
- engine.destroySession();
279
- }
400
+ this.orchestrator.shutdown();
401
+ if (engine.destroySession) engine.destroySession();
280
402
  }
281
403
  }
282
404
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "orchestrix-yuri",
3
- "version": "3.1.2",
3
+ "version": "3.2.0",
4
4
  "description": "Yuri — Meta-Orchestrator for Orchestrix. Drive your entire project lifecycle with natural language.",
5
5
  "main": "lib/installer.js",
6
6
  "bin": {
@@ -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