orchestrix-yuri 3.3.0 → 3.4.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/README.md CHANGED
@@ -8,10 +8,10 @@ User describes idea → Yuri drives: Create → Plan → Develop → Test → De
8
8
 
9
9
  ## How It Works
10
10
 
11
- Yuri is a [Claude Code skill](https://code.claude.com/docs/en/skills) + Channel Gateway. It can be used in two ways:
11
+ Yuri is a [Claude Code skill](https://code.claude.com/docs/en/skills) + Channel Gateway. Two ways to use:
12
12
 
13
13
  1. **Terminal mode** — activate `/yuri` inside any Claude Code session
14
- 2. **Telegram mode** — chat with Yuri via Telegram bot, backed by a persistent tmux Claude Code session
14
+ 2. **Telegram mode** — chat with Yuri via Telegram bot
15
15
 
16
16
  | Phase | What Yuri Does | Agents Involved |
17
17
  |-------|---------------|-----------------|
@@ -34,35 +34,38 @@ Yuri is a [Claude Code skill](https://code.claude.com/docs/en/skills) + Channel
34
34
  ### Method A: From npm (recommended)
35
35
 
36
36
  ```bash
37
- # Install globally
38
37
  npm install -g orchestrix-yuri
39
-
40
- # Initialize skill + global memory
41
38
  orchestrix-yuri install
42
-
43
- # Start Telegram gateway
44
- orchestrix-yuri serve --telegram-token "YOUR_BOT_TOKEN"
39
+ orchestrix-yuri start --token "YOUR_BOT_TOKEN" # first time, saves token
40
+ orchestrix-yuri start # from now on
45
41
  ```
46
42
 
47
43
  ### Method B: From source
48
44
 
49
45
  ```bash
50
- git clone https://github.com/anthropics/orchestrix-yuri.git
46
+ git clone https://github.com/dorayo/orchestrix-yuri.git
51
47
  cd orchestrix-yuri
52
48
  npm install
53
-
54
- # Initialize skill + global memory
55
49
  node bin/install.js install
56
-
57
- # Start Telegram gateway
58
- node bin/serve.js --telegram-token "YOUR_BOT_TOKEN"
50
+ node bin/serve.js --token "YOUR_BOT_TOKEN"
59
51
  ```
60
52
 
61
53
  ### What `install` does
62
54
 
63
55
  - Copies the Yuri skill to `~/.claude/skills/yuri/`
64
- - Initializes global memory at `~/.yuri/` (identity, boss profile, portfolio registry, focus, wisdom)
65
- - Creates channel config at `~/.yuri/config/channels.yaml`
56
+ - Initializes global memory at `~/.yuri/` (identity, boss profile, portfolio, focus, wisdom)
57
+ - Writes a bootstrap signal so Yuri greets you on first interaction
58
+
59
+ ## CLI Commands
60
+
61
+ ```bash
62
+ orchestrix-yuri install # Install Yuri skill + global memory
63
+ orchestrix-yuri start # Start the Channel Gateway
64
+ orchestrix-yuri start --token TOKEN # Start & save Telegram Bot token (first time)
65
+ orchestrix-yuri stop # Stop the running gateway
66
+ orchestrix-yuri status # Show gateway status + cost tracking
67
+ orchestrix-yuri --version # Show version
68
+ ```
66
69
 
67
70
  ## Usage
68
71
 
@@ -74,168 +77,130 @@ In any Claude Code session:
74
77
  /yuri
75
78
  ```
76
79
 
77
- Then either describe your project idea in natural language, or use a specific command:
80
+ ### Telegram Mode
78
81
 
79
82
  | Command | Description |
80
83
  |---------|-------------|
81
- | `*create` | Create a new project (Phase 1) |
82
- | `*plan` | Start/resume project planning (Phase 2) |
83
- | `*develop` | Start/resume automated development (Phase 3) |
84
- | `*test` | Start/resume smoke testing (Phase 4) |
85
- | `*deploy` | Start/resume deployment (Phase 5) |
86
- | `*status` | Show current project phase and progress |
87
- | `*resume` | Resume from last saved checkpoint |
88
- | `*change "{desc}"` | Handle mid-project requirement change |
89
-
90
- ### Telegram Mode
91
-
92
- Start the gateway and chat with Yuri via your Telegram bot:
84
+ | `*create` | Create a new project |
85
+ | `*plan` | Start planning (runs in background, agents auto-chain) |
86
+ | `*develop` | Start development (4 agents run autonomously) |
87
+ | `*test` | Run smoke tests |
88
+ | `*deploy` | Deploy the project |
89
+ | `*status` | Show progress + cost tracking |
90
+ | `*projects` | List all registered projects |
91
+ | `*switch <name>` | Switch active project |
92
+ | `*cancel` | Stop running phase |
93
+ | `*resume` | Resume from last checkpoint |
94
+ | `*change "{desc}"` | Handle requirement change |
95
+
96
+ ### Agent Interaction via Telegram
97
+
98
+ When a planning agent asks a question (e.g., PM asking to confirm features), Yuri bridges it to Telegram:
93
99
 
94
- ```bash
95
- # With token as CLI argument
96
- orchestrix-yuri serve --telegram-token "YOUR_BOT_TOKEN"
97
-
98
- # Or configure in ~/.yuri/config/channels.yaml first
99
- orchestrix-yuri serve
100
100
  ```
101
+ Yuri: "📋 PM is asking:
102
+ Which features to include?
103
+ 1. User auth 2. Dashboard 3. API
104
+ ↩️ Reply to this message to answer"
101
105
 
102
- #### Channel Configuration
103
-
104
- Edit `~/.yuri/config/channels.yaml`:
105
-
106
- ```yaml
107
- server:
108
- port: 7890
109
-
110
- channels:
111
- telegram:
112
- enabled: true
113
- token: "YOUR_BOT_TOKEN"
114
- mode: polling
115
- owner_chat_id: "" # Auto-bound on first /start
116
-
117
- engine:
118
- skill: yuri
119
- tmux_session: yuri-gateway
120
- startup_timeout: 30000
121
- poll_interval: 2000
122
- timeout: 300000
123
- autocompact_pct: 80
124
- compact_every: 50
106
+ You reply: "1 and 2, skip 3" ← forwarded to PM agent
125
107
  ```
126
108
 
127
- #### First-time Telegram setup
128
-
129
- 1. Start the gateway: `orchestrix-yuri serve --telegram-token "YOUR_TOKEN"`
130
- 2. Open Telegram, find your bot, send `/start`
131
- 3. The first user to send `/start` becomes the owner (all others are rejected)
132
- 4. Send any message to interact with Yuri
109
+ **Normal messages** (not reply-to) always go to Yuri directly.
133
110
 
134
111
  ## Architecture
135
112
 
136
- ### Persistent tmux Engine
137
-
138
- The Telegram gateway runs Claude Code in a persistent tmux session (`yuri-gateway`), not as a one-shot subprocess. This means:
113
+ ### Gateway Engine (claude-sdk)
139
114
 
140
- - **MCP servers connect once** and stay connected across messages
141
- - **Conversation context is preserved** natively by Claude Code
142
- - **No cold-start per message** — only the first message incurs startup latency
115
+ Each Telegram message is processed via `claude -p --output-format json`:
143
116
 
144
- State detection uses Claude Code's TUI indicators:
117
+ - **First message**: `--system-prompt` injects Yuri persona (SKILL.md) + L1 memory + channel instructions
118
+ - **Subsequent**: `--resume SESSION_ID` preserves conversation context
119
+ - **Output**: Structured JSON with `result` (clean text), `session_id`, `total_cost_usd`
145
120
 
146
- | Symbol | State | Description |
147
- |--------|-------|-------------|
148
- | `○` | Idle | Waiting for input |
149
- | `●` | Processing | Generating a response |
150
- | `◐` | Approval | Permission prompt (auto-approved) |
151
- | `[Verb]ed for Ns` | Complete | Response finished (e.g. "Baked for 31s") |
121
+ ### Async Phase Orchestration
152
122
 
153
- ### Context Management (3-layer)
123
+ `*plan` and `*develop` run as **Node.js background tasks** (not inside a `claude -p` call):
154
124
 
155
- 1. **CLAUDE.md persistence** — core instructions are written to project CLAUDE.md, which survives auto-compact
156
- 2. **Early auto-compact** — `CLAUDE_AUTOCOMPACT_PCT_OVERRIDE=80` triggers compaction at 80% (not the default 95%)
157
- 3. **Proactive /compact** — every 50 messages, a `/compact` is sent to keep context lean
125
+ ```
126
+ *plan PhaseOrchestrator.startPlan() instant reply
127
+
128
+ setInterval(30s) polls tmux agent → detects completion
129
+
130
+ starts next agent → proactive Telegram notification
131
+
132
+ user asks anything → normal Claude response (not blocked)
133
+ ```
158
134
 
159
- ### Memory System
135
+ tmux is used **only for multi-agent orchestration** (plan/develop), not for the gateway itself.
160
136
 
161
- Yuri maintains a four-layer global memory at `~/.yuri/`:
137
+ ### Memory System (4 layers)
162
138
 
163
139
  ```
164
- ~/.yuri/
165
- ├── self.yaml # Yuri identity
166
- ├── boss/
167
- │ ├── profile.yaml # Boss profile
168
- │ └── preferences.yaml # Boss preferences
169
- ├── portfolio/
170
- │ ├── registry.yaml # All projects (active/archived)
171
- │ ├── priorities.yaml # Portfolio priorities
172
- │ └── relationships.yaml # Project relationships
173
- ├── focus.yaml # Current focus & state
174
- ├── config/
175
- │ └── channels.yaml # Gateway channel config
176
- ├── chat-history/ # JSONL per chat_id
177
- ├── inbox.jsonl # Observation signals
178
- └── wisdom/ # Accumulated knowledge
140
+ L1 Global (~/.yuri/) — Yuri identity, boss profile, portfolio, focus
141
+ L2 Project ({project}/.yuri/) — Project identity, knowledge, decisions
142
+ L3 Phase State (state/) — Per-phase operational status
143
+ L4 Events (timeline/) — Append-only event history
179
144
  ```
180
145
 
181
- ### tmux Sessions
146
+ **Reflect Engine**: Signals detected from user messages (preferences, identity, priorities) are written to `inbox.jsonl`, then processed by `reflect.js` into the corresponding YAML files before each Claude call.
182
147
 
183
- | Session | Purpose | Windows |
184
- |---------|---------|---------|
185
- | `yuri-gateway` | Telegram channel gateway | 1 (Claude Code interactive) |
186
- | `op-{project}` | Planning phase | One per agent (Analyst, PM, UX, Architect, PO) |
187
- | `orchestrix-{repo-id}` | Development phase | 4 fixed (Architect, SM, Dev, QA) |
148
+ ### Context Management
149
+
150
+ - `CLAUDE_AUTOCOMPACT_PCT_OVERRIDE=80` triggers auto-compact at 80%
151
+ - Proactive `/compact` every 50 messages
152
+ - L1 hash tracking: when memory changes, a `[CONTEXT UPDATE]` prefix is injected into the next message
188
153
 
189
154
  ### File Structure
190
155
 
191
156
  ```
192
157
  orchestrix-yuri/
193
158
  ├── bin/
194
- │ ├── install.js # CLI entry (install / serve / migrate)
195
- └── serve.js # Gateway launcher
159
+ │ ├── install.js # CLI entry (install/start/stop/status)
160
+ ├── serve.js # Gateway launcher
161
+ │ ├── stop.js # PID-based stop
162
+ │ └── status.js # Gateway + cost status
196
163
  ├── lib/
197
- │ ├── installer.js # Global install logic
198
- │ ├── migrate.js # v1 → v2 memory migration
164
+ │ ├── installer.js # Global install logic
165
+ │ ├── migrate.js # v1 → v2 memory migration
199
166
  │ └── gateway/
200
- │ ├── index.js # startGateway()
201
- │ ├── config.js # Config loading + defaults
202
- │ ├── router.js # Message routing + 5-engine orchestration
203
- │ ├── binding.js # Owner authentication
204
- │ ├── history.js # Chat history (JSONL)
167
+ │ ├── index.js # startGateway()
168
+ │ ├── config.js # Config loading + defaults
169
+ │ ├── router.js # Message routing, phase detection, signal detection
170
+ │ ├── binding.js # Owner authentication
171
+ │ ├── history.js # Chat history (JSONL)
172
+ │ ├── log.js # Colored terminal logging
205
173
  │ ├── channels/
206
- │ │ └── telegram.js # grammy Telegram adapter
174
+ │ │ └── telegram.js # grammy adapter (placeholder + edit pattern)
207
175
  │ └── engine/
208
- └── claude-tmux.js # Persistent tmux session engine
176
+ ├── claude-sdk.js # Claude -p JSON engine + session management
177
+ │ ├── phase-orchestrator.js # Background plan/develop execution
178
+ │ ├── reflect.js # Inbox signal processing → YAML memory
179
+ │ └── tmux-utils.js # Shared tmux operations
209
180
  └── skill/
210
- ├── SKILL.md # Agent persona
211
- ├── tasks/ # Phase workflow instructions
212
- ├── scripts/ # Shell scripts (tmux, monitoring)
213
- ├── templates/ # Memory schema
214
- ├── data/ # Decision rules
215
- └── resources/ # MCP config, hooks, tmux scripts
181
+ ├── SKILL.md # Yuri persona + tmux command rules
182
+ ├── tasks/ # Phase workflow instructions
183
+ ├── scripts/ # Shell scripts (tmux, monitoring)
184
+ ├── templates/ # Memory schema
185
+ ├── data/ # Decision rules, signal taxonomy
186
+ └── resources/ # MCP config, hooks, tmux scripts
216
187
  ```
217
188
 
218
- ## Change Management
189
+ ### tmux Sessions (for agent orchestration)
190
+
191
+ | Session | Purpose | Windows |
192
+ |---------|---------|---------|
193
+ | `op-{project}` | Planning phase | One per agent |
194
+ | `orchestrix-{repo-id}` | Development phase | 4 fixed (Architect, SM, Dev, QA) |
219
195
 
220
- Yuri handles mid-project changes based on scope:
196
+ ## Change Management
221
197
 
222
198
  | Scope | Action |
223
199
  |-------|--------|
224
200
  | Small (≤5 files) | Dev `*solo` directly |
225
201
  | Medium | PO `*route-change` → standard workflow |
226
202
  | Large | Pause dev, partial re-plan |
227
- | New iteration | PM `*start-iteration` → parse next-steps.md drive agents resume dev |
228
-
229
- ## Deployment Options
230
-
231
- | Region | Provider | Best For |
232
- |--------|----------|----------|
233
- | China | Sealos | Prototype / MVP |
234
- | China | Aliyun ECS + Docker | Production |
235
- | China | Vercel (CN) | Frontend / SSR |
236
- | Overseas | Vercel | Frontend / Full-stack |
237
- | Overseas | Railway | Backend APIs |
238
- | Overseas | AWS / GCP | Enterprise |
203
+ | New iteration | PM `*start-iteration` → plandevtest |
239
204
 
240
205
  ## Troubleshooting
241
206
 
@@ -243,17 +208,23 @@ Yuri handles mid-project changes based on scope:
243
208
  # Check prerequisites
244
209
  tmux -V && which claude && node -v
245
210
 
246
- # View gateway logs (all output goes to stdout)
247
- orchestrix-yuri serve --telegram-token "..." 2>&1 | tee gateway.log
211
+ # View gateway logs
212
+ orchestrix-yuri start 2>&1 | tee gateway.log
213
+
214
+ # Check status + cost
215
+ orchestrix-yuri status
216
+
217
+ # Stop the gateway
218
+ orchestrix-yuri stop
248
219
 
249
- # Check if tmux session is alive
220
+ # Check running tmux sessions (plan/develop phases)
250
221
  tmux ls
251
222
 
252
- # Peek into the Claude Code session
253
- tmux attach -t yuri-gateway
223
+ # Peek into a planning agent
224
+ tmux attach -t op-myproject
254
225
 
255
- # Manual cleanup if session gets stuck
256
- tmux kill-session -t yuri-gateway
226
+ # Peek into dev agents
227
+ tmux attach -t orchestrix-myproject
257
228
  ```
258
229
 
259
230
  ## License
package/bin/status.js CHANGED
@@ -30,6 +30,9 @@ function status() {
30
30
  const age = Date.now() - new Date(s.savedAt).getTime();
31
31
  if (age < 24 * 3600_000) {
32
32
  sessionInfo = `${s.sessionId.slice(0, 8)}... (${s.messageCount || 0} messages)`;
33
+ if (s.totalCost) {
34
+ sessionInfo += ` | $${s.totalCost.toFixed(4)}`;
35
+ }
33
36
  } else {
34
37
  sessionInfo = 'expired';
35
38
  }
@@ -39,16 +39,32 @@ class TelegramAdapter {
39
39
  };
40
40
 
41
41
  try {
42
+ // Send placeholder immediately so user sees instant feedback
43
+ const placeholder = await ctx.reply('...').catch(() => null);
44
+
42
45
  const reply = await this.onMessage(msg);
43
46
  if (reply && reply.text) {
44
- // Telegram has a 4096 char limit per message
45
47
  const chunks = splitMessage(reply.text, 4000);
46
- for (const chunk of chunks) {
47
- await ctx.reply(chunk, { parse_mode: 'Markdown' }).catch(() => {
48
- // Fallback: send without markdown if parsing fails
49
- return ctx.reply(chunk);
48
+
49
+ if (placeholder && chunks.length === 1) {
50
+ // Single chunk: edit the placeholder message in-place
51
+ await ctx.api.editMessageText(ctx.chat.id, placeholder.message_id, chunks[0], { parse_mode: 'Markdown' }).catch(() => {
52
+ return ctx.api.editMessageText(ctx.chat.id, placeholder.message_id, chunks[0]).catch(() => {});
50
53
  });
54
+ } else {
55
+ // Multi-chunk: delete placeholder, send chunks separately
56
+ if (placeholder) {
57
+ await ctx.api.deleteMessage(ctx.chat.id, placeholder.message_id).catch(() => {});
58
+ }
59
+ for (const chunk of chunks) {
60
+ await ctx.reply(chunk, { parse_mode: 'Markdown' }).catch(() => {
61
+ return ctx.reply(chunk);
62
+ });
63
+ }
51
64
  }
65
+ } else if (placeholder) {
66
+ // No reply: remove placeholder
67
+ await ctx.api.deleteMessage(ctx.chat.id, placeholder.message_id).catch(() => {});
52
68
  }
53
69
  } catch (err) {
54
70
  log.error(`Message handling failed: ${err.message}`);
@@ -117,6 +117,8 @@ let _sessionId = null;
117
117
  let _messageCount = 0;
118
118
  let _messageQueue = Promise.resolve();
119
119
  let _lastL1Hash = null;
120
+ let _totalCost = 0;
121
+ let _totalDuration = 0;
120
122
 
121
123
  // ── System Prompt ──────────────────────────────────────────────────────────────
122
124
 
@@ -188,6 +190,8 @@ function saveSessionState() {
188
190
  version: SESSION_VERSION,
189
191
  sessionId: _sessionId,
190
192
  messageCount: _messageCount,
193
+ totalCost: _totalCost,
194
+ totalDuration: _totalDuration,
191
195
  savedAt: new Date().toISOString(),
192
196
  }));
193
197
  } catch { /* best effort */ }
@@ -290,6 +294,8 @@ async function callClaude(opts) {
290
294
  log.engine(`Restoring session ${saved.sessionId.slice(0, 8)}...`);
291
295
  _sessionId = saved.sessionId;
292
296
  _messageCount = saved.messageCount || 0;
297
+ _totalCost = saved.totalCost || 0;
298
+ _totalDuration = saved.totalDuration || 0;
293
299
  }
294
300
  }
295
301
 
@@ -326,6 +332,8 @@ async function callClaude(opts) {
326
332
  }
327
333
 
328
334
  _messageCount++;
335
+ if (result.cost) _totalCost += result.cost;
336
+ if (result.duration) _totalDuration += result.duration;
329
337
  saveSessionState();
330
338
 
331
339
  // If --resume failed (session expired), retry with fresh session
@@ -385,10 +393,20 @@ function composePrompt(userMessage) {
385
393
  * Destroy session state. Called on gateway shutdown.
386
394
  */
387
395
  function destroySession() {
388
- // Don't clear session file — allow restart to resume
389
396
  log.engine('Gateway shutting down (session preserved for restart)');
390
397
  }
391
398
 
399
+ /**
400
+ * Get accumulated cost and usage stats.
401
+ */
402
+ function getUsageStats() {
403
+ return {
404
+ messageCount: _messageCount,
405
+ totalCost: _totalCost,
406
+ totalDuration: _totalDuration,
407
+ };
408
+ }
409
+
392
410
  module.exports = {
393
411
  callClaude,
394
412
  composePrompt,
@@ -396,4 +414,8 @@ module.exports = {
396
414
  resolveProjectRoot,
397
415
  findClaudeBinary,
398
416
  destroySession,
417
+ clearSessionState,
418
+ getUsageStats,
419
+ // Exported for testing
420
+ isEmptyTemplate,
399
421
  };
@@ -112,6 +112,43 @@ class PhaseOrchestrator {
112
112
  }
113
113
  }
114
114
 
115
+ /**
116
+ * Capture the current agent's tmux pane content for Claude context injection.
117
+ * Returns a formatted string or null if no agent is active.
118
+ */
119
+ captureCurrentAgentContext() {
120
+ if (!this._phase || !this._session || !tmx.hasSession(this._session)) {
121
+ return null;
122
+ }
123
+
124
+ if (this._phase === 'plan') {
125
+ const agent = PLAN_AGENTS[this._step];
126
+ if (!agent) return null;
127
+
128
+ const pane = tmx.capturePane(this._session, agent.window, 40);
129
+ if (!pane.trim()) return null;
130
+
131
+ const waiting = this._waitingForInput ? ' (WAITING FOR YOUR INPUT)' : '';
132
+ return `[LIVE AGENT CONTEXT] Phase: plan, Agent: ${agent.name} (${this._step + 1}/${PLAN_AGENTS.length})${waiting}\n` +
133
+ `tmux session: ${this._session}, window: ${agent.window}\n` +
134
+ `--- Agent output (last 40 lines) ---\n${pane}\n--- End agent output ---`;
135
+ }
136
+
137
+ if (this._phase === 'develop') {
138
+ // Capture all 4 dev windows briefly
139
+ const windows = ['Architect', 'SM', 'Dev', 'QA'];
140
+ const summaries = [];
141
+ for (let w = 0; w < 4; w++) {
142
+ const tail = tmx.capturePane(this._session, w, 5);
143
+ const lastLine = tail.split('\n').filter((l) => l.trim()).pop() || '(empty)';
144
+ summaries.push(` Window ${w} (${windows[w]}): ${lastLine.trim().slice(0, 80)}`);
145
+ }
146
+ return `[LIVE AGENT CONTEXT] Phase: develop, Session: ${this._session}\n${summaries.join('\n')}`;
147
+ }
148
+
149
+ return null;
150
+ }
151
+
115
152
  getStatus() {
116
153
  if (!this._phase) {
117
154
  return { phase: null, message: 'No phase is running.' };
@@ -24,6 +24,11 @@ const PHASE_COMMANDS = {
24
24
  cancel: /^\*cancel\b/i,
25
25
  };
26
26
 
27
+ const META_COMMANDS = {
28
+ projects: /^\*projects\b/i,
29
+ switch: /^\*switch\s+(.+)/i,
30
+ };
31
+
27
32
  const STATUS_PATTERNS = [
28
33
  /^\*status\b/i,
29
34
  /进度|状态|怎么样了|到哪了/,
@@ -128,6 +133,15 @@ class Router {
128
133
  return this._handleStatusQuery(msg);
129
134
  }
130
135
 
136
+ // ═══ META COMMANDS — *projects, *switch (always allowed) ═══
137
+ if (META_COMMANDS.projects.test(msg.text.trim())) {
138
+ return this._handleProjects(msg);
139
+ }
140
+ const switchMatch = msg.text.trim().match(META_COMMANDS.switch);
141
+ if (switchMatch) {
142
+ return this._handleSwitch(switchMatch[1].trim(), msg);
143
+ }
144
+
131
145
  // ═══ AGENT REPLY — user replied to an agent question notification ═══
132
146
  if (msg.replyToMessageId && this.orchestrator.isWaitingForInput()) {
133
147
  const waitingId = this.orchestrator.getWaitingMessageId();
@@ -252,8 +266,14 @@ class Router {
252
266
  }
253
267
  }
254
268
 
269
+ // Cost tracking
270
+ const usage = engine.getUsageStats();
271
+ if (usage.totalCost > 0) {
272
+ parts.push(`\n💰 Cost: $${usage.totalCost.toFixed(4)} | ${usage.messageCount} messages | ${(usage.totalDuration / 1000).toFixed(0)}s total`);
273
+ }
274
+
255
275
  if (parts.length === 0) {
256
- parts.push('No active phase. Available commands: *create, *plan, *develop, *test, *deploy');
276
+ parts.push('No active phase. Available commands: *create, *plan, *develop, *test, *deploy, *projects, *switch');
257
277
  }
258
278
 
259
279
  this.history.append(msg.chatId, 'user', msg.text);
@@ -270,7 +290,15 @@ class Router {
270
290
  await this._runCatchUp();
271
291
 
272
292
  const projectRoot = engine.resolveProjectRoot();
273
- const prompt = engine.composePrompt(msg.text);
293
+
294
+ // If a phase is running, inject tmux pane context so Claude can see agent state
295
+ let prompt = engine.composePrompt(msg.text);
296
+ if (this.orchestrator.isRunning()) {
297
+ const agentContext = this.orchestrator.captureCurrentAgentContext();
298
+ if (agentContext) {
299
+ prompt = `${agentContext}\n\n---\n\nUser message: ${msg.text}`;
300
+ }
301
+ }
274
302
 
275
303
  log.router(`Processing: "${msg.text.slice(0, 80)}..." → cwd: ${projectRoot || '~'}`);
276
304
  const result = await engine.callClaude({
@@ -426,6 +454,80 @@ class Router {
426
454
  }
427
455
  }
428
456
 
457
+ // ── Multi-Project Commands ────────────────────────────────────────────────────
458
+
459
+ _handleProjects(msg) {
460
+ const registryPath = path.join(YURI_GLOBAL, 'portfolio', 'registry.yaml');
461
+ if (!fs.existsSync(registryPath)) {
462
+ return { text: 'No projects registered yet. Use *create to create one.' };
463
+ }
464
+
465
+ const registry = yaml.load(fs.readFileSync(registryPath, 'utf8')) || {};
466
+ const projects = registry.projects || [];
467
+ if (projects.length === 0) {
468
+ return { text: 'No projects registered yet. Use *create to create one.' };
469
+ }
470
+
471
+ // Read active project
472
+ const focusPath = path.join(YURI_GLOBAL, 'focus.yaml');
473
+ let activeId = '';
474
+ if (fs.existsSync(focusPath)) {
475
+ const focus = yaml.load(fs.readFileSync(focusPath, 'utf8')) || {};
476
+ activeId = focus.active_project || '';
477
+ }
478
+
479
+ const statusIcons = { active: '✅', paused: '💤', maintenance: '🔧', archived: '📦' };
480
+ const lines = projects.map((p, i) => {
481
+ const icon = statusIcons[p.status] || '❓';
482
+ const isCurrent = p.id === activeId ? ' ← current' : '';
483
+ return `${i + 1}. **${p.name || p.id}** ${icon} ${p.status} (Phase ${p.phase || '?'}: ${p.pulse || '?'})${isCurrent}`;
484
+ });
485
+
486
+ const reply = `📂 **Projects**\n\n${lines.join('\n')}\n\nUse \`*switch <name>\` to change active project.`;
487
+ this.history.append(msg.chatId, 'user', msg.text);
488
+ this.history.append(msg.chatId, 'assistant', reply);
489
+ return { text: reply };
490
+ }
491
+
492
+ _handleSwitch(query, msg) {
493
+ const registryPath = path.join(YURI_GLOBAL, 'portfolio', 'registry.yaml');
494
+ if (!fs.existsSync(registryPath)) {
495
+ return { text: '❌ No projects registered.' };
496
+ }
497
+
498
+ const registry = yaml.load(fs.readFileSync(registryPath, 'utf8')) || {};
499
+ const projects = registry.projects || [];
500
+ const q = query.toLowerCase();
501
+
502
+ // Fuzzy match: exact id, exact name, prefix of id, prefix of name
503
+ const match = projects.find((p) => p.id === q || (p.name && p.name.toLowerCase() === q))
504
+ || projects.find((p) => p.id.startsWith(q) || (p.name && p.name.toLowerCase().startsWith(q)));
505
+
506
+ if (!match) {
507
+ return { text: `❌ No project matching "${query}". Use *projects to see available projects.` };
508
+ }
509
+
510
+ // Update focus
511
+ const focusPath = path.join(YURI_GLOBAL, 'focus.yaml');
512
+ let focus = {};
513
+ if (fs.existsSync(focusPath)) {
514
+ focus = yaml.load(fs.readFileSync(focusPath, 'utf8')) || {};
515
+ }
516
+ focus.active_project = match.id;
517
+ focus.active_action = `switched to project: ${match.name || match.id}`;
518
+ focus.updated_at = new Date().toISOString();
519
+ fs.writeFileSync(focusPath, yaml.dump(focus, { lineWidth: -1 }));
520
+
521
+ // Clear Claude session so next message gets fresh system prompt for new project
522
+ engine.clearSessionState();
523
+
524
+ const reply = `✅ Switched to **${match.name || match.id}** (Phase ${match.phase || '?'}: ${match.pulse || '?'})`;
525
+ this.history.append(msg.chatId, 'user', msg.text);
526
+ this.history.append(msg.chatId, 'assistant', reply);
527
+ log.router(`Project switched to: ${match.id}`);
528
+ return { text: reply };
529
+ }
530
+
429
531
  _updateGlobalFocus(msg, projectRoot) {
430
532
  const focusPath = path.join(YURI_GLOBAL, 'focus.yaml');
431
533
  if (!fs.existsSync(focusPath)) return;
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "orchestrix-yuri",
3
- "version": "3.3.0",
3
+ "version": "3.4.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": {
7
7
  "orchestrix-yuri": "bin/install.js"
8
8
  },
9
9
  "scripts": {
10
- "test": "echo \"No tests yet\" && exit 0",
10
+ "test": "node --test 'test/*.test.js'",
11
11
  "serve": "node bin/serve.js"
12
12
  },
13
13
  "keywords": [