@yeaft/webchat-agent 0.1.62 → 0.1.64

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/claude.js CHANGED
@@ -1,7 +1,15 @@
1
1
  import { query, Stream } from './sdk/index.js';
2
2
  import ctx from './context.js';
3
3
  import { sendConversationList, sendOutput, sendError, handleAskUserQuestion } from './conversation.js';
4
- import { buildRolePlaySystemPrompt } from './roleplay.js';
4
+ import {
5
+ buildRolePlaySystemPrompt,
6
+ rolePlaySessions,
7
+ initRolePlayRouteState,
8
+ detectRoleSignal,
9
+ processRolePlayRoutes,
10
+ buildRouteEventMessage,
11
+ getRolePlayRouteState
12
+ } from './roleplay.js';
5
13
 
6
14
  /**
7
15
  * Start a Claude SDK query for a conversation
@@ -89,6 +97,12 @@ export async function startClaudeQuery(conversationId, workDir, resumeSessionId)
89
97
  if (savedRolePlayConfig) {
90
98
  options.appendSystemPrompt = buildRolePlaySystemPrompt(savedRolePlayConfig);
91
99
  console.log(`[SDK] RolePlay appendSystemPrompt injected (teamType: ${savedRolePlayConfig.teamType})`);
100
+
101
+ // Initialize RolePlay route state if session exists
102
+ const rpSession = rolePlaySessions.get(conversationId);
103
+ if (rpSession) {
104
+ initRolePlayRouteState(rpSession, state);
105
+ }
92
106
  }
93
107
 
94
108
  // Validate session ID is a valid UUID before using it
@@ -414,6 +428,51 @@ async function processClaudeOutput(conversationId, claudeQuery, state) {
414
428
  continue;
415
429
  }
416
430
 
431
+ // ★ RolePlay ROUTE detection: check accumulated text for ROUTE blocks
432
+ const rpSession = state.rolePlayConfig ? rolePlaySessions.get(conversationId) : null;
433
+ let roleplayAutoContinue = false;
434
+ let roleplayContinueRoles = [];
435
+
436
+ if (rpSession && rpSession._routeInitialized && state._roleplayAccumulated) {
437
+ const { routes, hasHumanRoute, continueRoles } = processRolePlayRoutes(
438
+ state._roleplayAccumulated, rpSession
439
+ );
440
+
441
+ if (routes.length > 0) {
442
+ // Send route events to frontend
443
+ for (const route of routes) {
444
+ const routeEvent = buildRouteEventMessage(
445
+ conversationId, rpSession.currentRole || 'unknown', route
446
+ );
447
+ ctx.sendToServer(routeEvent);
448
+ }
449
+
450
+ // Send route state update
451
+ const routeState = getRolePlayRouteState(conversationId);
452
+ if (routeState) {
453
+ ctx.sendToServer({
454
+ type: 'roleplay_status',
455
+ conversationId,
456
+ ...routeState
457
+ });
458
+ }
459
+
460
+ if (hasHumanRoute) {
461
+ // Stop auto-continue, wait for user input
462
+ ctx.sendToServer({
463
+ type: 'roleplay_waiting_human',
464
+ conversationId,
465
+ fromRole: rpSession.currentRole,
466
+ message: rpSession.waitingHumanContext?.message || ''
467
+ });
468
+ } else if (continueRoles.length > 0) {
469
+ // Auto-continue: pick the first route target and send the prompt
470
+ roleplayAutoContinue = true;
471
+ roleplayContinueRoles = continueRoles;
472
+ }
473
+ }
474
+ }
475
+
417
476
  // ★ Turn 完成:发送 turn_completed,进程继续运行等待下一条消息
418
477
  // stream-json 模式下 Claude 进程是持久运行的,for-await 在 result 后继续等待
419
478
  // 不清空 state.query 和 state.inputStream,下次用户消息直接通过同一个 inputStream 发送
@@ -424,6 +483,47 @@ async function processClaudeOutput(conversationId, claudeQuery, state) {
424
483
  // ★ await 确保 result 和 turn_completed 消息确实发送成功
425
484
  // 不 await 会导致 encrypt 失败时消息静默丢失,前端卡在"思考中"
426
485
  await sendOutput(conversationId, message);
486
+
487
+ // ★ RolePlay auto-continue: inject next role's prompt into the same conversation
488
+ if (roleplayAutoContinue && rpSession && state.inputStream) {
489
+ // Reset accumulated text for next turn
490
+ state._roleplayAccumulated = '';
491
+
492
+ for (const { to, prompt, taskId, taskTitle } of roleplayContinueRoles) {
493
+ rpSession.currentRole = to;
494
+ if (rpSession.roleStates[to]) {
495
+ rpSession.roleStates[to].status = 'active';
496
+ }
497
+
498
+ console.log(`[RolePlay] Auto-continuing to role: ${to}`);
499
+
500
+ // Re-activate the turn
501
+ state.turnActive = true;
502
+ state.turnResultReceived = false;
503
+
504
+ // Send the continuation prompt through the same input stream
505
+ const userMessage = {
506
+ type: 'user',
507
+ message: { role: 'user', content: prompt }
508
+ };
509
+ sendOutput(conversationId, userMessage);
510
+ state.inputStream.enqueue(userMessage);
511
+
512
+ // RolePlay uses a single conversation — only one target can be active
513
+ // at a time. Additional route targets are ignored.
514
+ break;
515
+ }
516
+
517
+ // Send status update (don't send turn_completed yet since we're continuing)
518
+ sendConversationList();
519
+ continue;
520
+ }
521
+
522
+ // Reset accumulated text
523
+ if (state._roleplayAccumulated !== undefined) {
524
+ state._roleplayAccumulated = '';
525
+ }
526
+
427
527
  await ctx.sendToServer({
428
528
  type: 'turn_completed',
429
529
  conversationId,
@@ -434,9 +534,61 @@ async function processClaudeOutput(conversationId, claudeQuery, state) {
434
534
  continue;
435
535
  }
436
536
 
537
+ // ★ RolePlay: accumulate assistant text and detect ROLE signals
538
+ if (state.rolePlayConfig && message.type === 'assistant' && message.message?.content) {
539
+ const content = message.message.content;
540
+ let textChunk = '';
541
+ if (typeof content === 'string') {
542
+ textChunk = content;
543
+ } else if (Array.isArray(content)) {
544
+ textChunk = content.filter(b => b.type === 'text').map(b => b.text).join('');
545
+ }
546
+ if (textChunk) {
547
+ if (state._roleplayAccumulated === undefined) {
548
+ state._roleplayAccumulated = '';
549
+ }
550
+ state._roleplayAccumulated += textChunk;
551
+
552
+ // Detect ROLE signal for current role tracking
553
+ const rpSession = rolePlaySessions.get(conversationId);
554
+ if (rpSession && rpSession._routeInitialized) {
555
+ const detectedRole = detectRoleSignal(textChunk);
556
+ if (detectedRole) {
557
+ const prevRole = rpSession.currentRole;
558
+ rpSession.currentRole = detectedRole;
559
+ if (rpSession.roleStates[detectedRole]) {
560
+ rpSession.roleStates[detectedRole].status = 'active';
561
+ }
562
+ if (prevRole && prevRole !== detectedRole && rpSession.roleStates[prevRole]) {
563
+ rpSession.roleStates[prevRole].status = 'idle';
564
+ }
565
+ console.log(`[RolePlay] Role switched: ${prevRole || 'none'} -> ${detectedRole}`);
566
+ }
567
+ }
568
+ }
569
+ }
570
+
437
571
  // 检测后台任务
438
572
  detectAndTrackBackgroundTask(conversationId, state, message);
439
573
 
574
+ // ★ RolePlay: attach role metadata to messages sent to frontend
575
+ if (state.rolePlayConfig) {
576
+ const rpSession = rolePlaySessions.get(conversationId);
577
+ if (rpSession && rpSession._routeInitialized) {
578
+ // Attach current role info to the message as metadata
579
+ const enrichedMessage = {
580
+ ...message,
581
+ _roleplay: {
582
+ role: rpSession.currentRole,
583
+ features: rpSession.features ? Array.from(rpSession.features.values()) : [],
584
+ round: rpSession.round
585
+ }
586
+ };
587
+ sendOutput(conversationId, enrichedMessage);
588
+ continue;
589
+ }
590
+ }
591
+
440
592
  sendOutput(conversationId, message);
441
593
  }
442
594
  } catch (error) {
@@ -12,7 +12,7 @@ import {
12
12
  createConversation, resumeConversation, deleteConversation,
13
13
  handleRefreshConversation, handleCancelExecution,
14
14
  handleUserInput, handleUpdateConversationSettings, handleAskUserAnswer,
15
- sendConversationList
15
+ sendConversationList, handleCheckCrewContext
16
16
  } from '../conversation.js';
17
17
  import {
18
18
  createCrewSession, handleCrewHumanInput, handleCrewControl,
@@ -66,6 +66,10 @@ export async function handleMessage(msg) {
66
66
  await createConversation(msg);
67
67
  break;
68
68
 
69
+ case 'check_crew_context':
70
+ handleCheckCrewContext(msg);
71
+ break;
72
+
69
73
  case 'resume_conversation':
70
74
  await resumeConversation(msg);
71
75
  break;
package/conversation.js CHANGED
@@ -2,7 +2,7 @@ import ctx from './context.js';
2
2
  import { loadSessionHistory } from './history.js';
3
3
  import { startClaudeQuery } from './claude.js';
4
4
  import { crewSessions, loadCrewIndex } from './crew.js';
5
- import { rolePlaySessions, saveRolePlayIndex, removeRolePlaySession, loadRolePlayIndex, validateRolePlayConfig } from './roleplay.js';
5
+ import { rolePlaySessions, saveRolePlayIndex, removeRolePlaySession, loadRolePlayIndex, validateRolePlayConfig, initRolePlayRouteState, loadCrewContext } from './roleplay.js';
6
6
 
7
7
  // Restore persisted roleplay sessions on module load (agent startup)
8
8
  loadRolePlayIndex();
@@ -54,7 +54,17 @@ export async function sendConversationList() {
54
54
  // roleplay conversations are stored in ctx.conversations but also tracked in rolePlaySessions
55
55
  if (rolePlaySessions.has(id)) {
56
56
  entry.type = 'rolePlay';
57
- entry.rolePlayRoles = rolePlaySessions.get(id).roles;
57
+ const rpSession = rolePlaySessions.get(id);
58
+ entry.rolePlayRoles = rpSession.roles;
59
+ // Include route state if initialized
60
+ if (rpSession._routeInitialized) {
61
+ entry.rolePlayState = {
62
+ currentRole: rpSession.currentRole,
63
+ round: rpSession.round,
64
+ features: rpSession.features ? Array.from(rpSession.features.values()) : [],
65
+ waitingHuman: rpSession.waitingHuman || false
66
+ };
67
+ }
58
68
  }
59
69
  list.push(entry);
60
70
  }
@@ -130,6 +140,15 @@ export async function createConversation(msg) {
130
140
  rolePlayConfig = result.config;
131
141
  }
132
142
 
143
+ // Load .crew context if rolePlay and projectDir contains .crew directory
144
+ if (rolePlayConfig) {
145
+ const crewContext = loadCrewContext(effectiveWorkDir);
146
+ if (crewContext) {
147
+ rolePlayConfig.crewContext = crewContext;
148
+ console.log(` RolePlay: loaded .crew context (${crewContext.roles.length} roles, ${crewContext.features.length} features)`);
149
+ }
150
+ }
151
+
133
152
  console.log(`Creating conversation: ${conversationId} in ${effectiveWorkDir} (lazy start)`);
134
153
  if (username) console.log(` User: ${username} (${userId})`);
135
154
  if (rolePlayConfig) console.log(` RolePlay: teamType=${rolePlayConfig.teamType}, roles=${rolePlayConfig.roles?.length}`);
@@ -161,7 +180,7 @@ export async function createConversation(msg) {
161
180
 
162
181
  // Register in rolePlaySessions for type inference in sendConversationList
163
182
  if (rolePlayConfig) {
164
- rolePlaySessions.set(conversationId, {
183
+ const rpSession = {
165
184
  roles: rolePlayConfig.roles,
166
185
  teamType: rolePlayConfig.teamType,
167
186
  language: rolePlayConfig.language,
@@ -169,7 +188,10 @@ export async function createConversation(msg) {
169
188
  createdAt: Date.now(),
170
189
  userId,
171
190
  username,
172
- });
191
+ };
192
+ rolePlaySessions.set(conversationId, rpSession);
193
+ // Initialize route state eagerly so it's ready when Claude starts
194
+ initRolePlayRouteState(rpSession, ctx.conversations.get(conversationId));
173
195
  saveRolePlayIndex();
174
196
  }
175
197
 
@@ -467,9 +489,29 @@ export async function handleUserInput(msg) {
467
489
 
468
490
  // 发送用户消息到输入流
469
491
  // Claude stream-json 模式支持在回复过程中接收新消息(写入 stdin)
492
+ let effectivePrompt = prompt;
493
+
494
+ // ★ RolePlay: if session was waiting for human input, clear the flag and
495
+ // wrap the user message with context about which role was asking
496
+ const rpSession = rolePlaySessions.get(conversationId);
497
+ if (rpSession && rpSession.waitingHuman && rpSession.waitingHumanContext) {
498
+ const { fromRole, message: requestMessage } = rpSession.waitingHumanContext;
499
+ const fromRoleConfig = rpSession.roles.find?.(r => r.name === fromRole) ||
500
+ (Array.isArray(rpSession.roles) ? rpSession.roles.find(r => r.name === fromRole) : null);
501
+ const fromLabel = fromRoleConfig
502
+ ? (fromRoleConfig.icon ? `${fromRoleConfig.icon} ${fromRoleConfig.displayName}` : fromRoleConfig.displayName)
503
+ : fromRole;
504
+
505
+ effectivePrompt = `人工回复(回应 ${fromLabel} 的请求: "${requestMessage}"):\n\n${prompt}`;
506
+
507
+ rpSession.waitingHuman = false;
508
+ rpSession.waitingHumanContext = null;
509
+ console.log(`[RolePlay] Human responded, resuming from ${fromRole}'s request`);
510
+ }
511
+
470
512
  const userMessage = {
471
513
  type: 'user',
472
- message: { role: 'user', content: prompt }
514
+ message: { role: 'user', content: effectivePrompt }
473
515
  };
474
516
 
475
517
  console.log(`[${conversationId}] Sending: ${prompt.substring(0, 100)}...`);
@@ -556,3 +598,38 @@ export function handleAskUserAnswer(msg) {
556
598
  console.log(`[AskUser] No pending question for requestId: ${msg.requestId}`);
557
599
  }
558
600
  }
601
+
602
+ /**
603
+ * Handle check_crew_context request — check if a directory has .crew context
604
+ * and return role/context info for RolePlay auto-import.
605
+ */
606
+ export function handleCheckCrewContext(msg) {
607
+ const { projectDir, requestId } = msg;
608
+ if (!projectDir) {
609
+ ctx.sendToServer({ type: 'crew_context_result', requestId, found: false });
610
+ return;
611
+ }
612
+ const crewContext = loadCrewContext(projectDir);
613
+ if (!crewContext) {
614
+ ctx.sendToServer({ type: 'crew_context_result', requestId, found: false });
615
+ return;
616
+ }
617
+ // Return a safe subset for the frontend (no full claudeMd content, just metadata)
618
+ ctx.sendToServer({
619
+ type: 'crew_context_result',
620
+ requestId,
621
+ found: true,
622
+ roles: crewContext.roles.map(r => ({
623
+ name: r.name,
624
+ displayName: r.displayName,
625
+ icon: r.icon,
626
+ description: r.description,
627
+ roleType: r.roleType,
628
+ isDecisionMaker: r.isDecisionMaker,
629
+ hasClaudeMd: !!(r.claudeMd && r.claudeMd.length > 0),
630
+ })),
631
+ teamType: crewContext.teamType,
632
+ language: crewContext.language,
633
+ featureCount: crewContext.features.length,
634
+ });
635
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "0.1.62",
3
+ "version": "0.1.64",
4
4
  "description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
5
5
  "main": "index.js",
6
6
  "type": "module",
package/roleplay.js CHANGED
@@ -1,13 +1,16 @@
1
1
  /**
2
2
  * Role Play — lightweight multi-role collaboration within a single conversation.
3
3
  *
4
- * Manages rolePlaySessions (in-memory + persisted to disk) and builds the
5
- * appendSystemPrompt that instructs Claude to role-play multiple characters.
4
+ * Manages rolePlaySessions (in-memory + persisted to disk), builds the
5
+ * appendSystemPrompt that instructs Claude to role-play multiple characters,
6
+ * and handles ROUTE protocol for Crew-style role switching within a single
7
+ * Claude conversation.
6
8
  */
7
9
 
8
10
  import { join } from 'path';
9
11
  import { homedir } from 'os';
10
- import { readFileSync, writeFileSync, existsSync, renameSync } from 'fs';
12
+ import { readFileSync, writeFileSync, existsSync, renameSync, readdirSync } from 'fs';
13
+ import { parseRoutes } from './crew/routing.js';
11
14
 
12
15
  const ROLEPLAY_INDEX_PATH = join(homedir(), '.claude', 'roleplay-sessions.json');
13
16
  // ★ backward compat: old filename before rename
@@ -23,7 +26,9 @@ export const rolePlaySessions = new Map();
23
26
  export function saveRolePlayIndex() {
24
27
  const data = [];
25
28
  for (const [id, session] of rolePlaySessions) {
26
- data.push({ id, ...session });
29
+ // Only persist core fields, skip runtime route state
30
+ const { _routeInitialized, currentRole, features, round, roleStates, waitingHuman, waitingHumanContext, ...core } = session;
31
+ data.push({ id, ...core });
27
32
  }
28
33
  try {
29
34
  writeFileSync(ROLEPLAY_INDEX_PATH, JSON.stringify(data, null, 2));
@@ -65,6 +70,147 @@ export function removeRolePlaySession(conversationId) {
65
70
  saveRolePlayIndex();
66
71
  }
67
72
 
73
+ // ---------------------------------------------------------------------------
74
+ // .crew context import
75
+ // ---------------------------------------------------------------------------
76
+
77
+ /**
78
+ * Read a file if it exists, otherwise return null.
79
+ * @param {string} filePath
80
+ * @returns {string|null}
81
+ */
82
+ function readFileOrNull(filePath) {
83
+ try {
84
+ if (!existsSync(filePath)) return null;
85
+ return readFileSync(filePath, 'utf-8');
86
+ } catch {
87
+ return null;
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Load .crew context from a project directory.
93
+ * Returns null if .crew/ doesn't exist.
94
+ *
95
+ * @param {string} projectDir - absolute path to project root
96
+ * @returns {{ sharedClaudeMd: string, roles: Array, kanban: string, features: Array, teamType: string, language: string } | null}
97
+ */
98
+ export function loadCrewContext(projectDir) {
99
+ const crewDir = join(projectDir, '.crew');
100
+ if (!existsSync(crewDir)) return null;
101
+
102
+ // 1. Shared CLAUDE.md
103
+ const sharedClaudeMd = readFileOrNull(join(crewDir, 'CLAUDE.md')) || '';
104
+
105
+ // 2. session.json → roles, teamType, language, features
106
+ let sessionRoles = [];
107
+ let teamType = 'dev';
108
+ let language = 'zh-CN';
109
+ let sessionFeatures = [];
110
+ const sessionPath = join(crewDir, 'session.json');
111
+ const sessionJson = readFileOrNull(sessionPath);
112
+ if (sessionJson) {
113
+ try {
114
+ const session = JSON.parse(sessionJson);
115
+ if (Array.isArray(session.roles)) {
116
+ sessionRoles = session.roles;
117
+ }
118
+ if (session.teamType) teamType = session.teamType;
119
+ if (session.language) language = session.language;
120
+ if (Array.isArray(session.features)) {
121
+ sessionFeatures = session.features;
122
+ }
123
+ } catch {
124
+ // Invalid JSON — ignore
125
+ }
126
+ }
127
+
128
+ // 3. Per-role CLAUDE.md from .crew/roles/*/CLAUDE.md
129
+ const roleClaudes = {};
130
+ const rolesDir = join(crewDir, 'roles');
131
+ if (existsSync(rolesDir)) {
132
+ try {
133
+ const roleDirs = readdirSync(rolesDir, { withFileTypes: true })
134
+ .filter(d => d.isDirectory())
135
+ .map(d => d.name);
136
+ for (const dirName of roleDirs) {
137
+ const md = readFileOrNull(join(rolesDir, dirName, 'CLAUDE.md'));
138
+ if (md) roleClaudes[dirName] = md;
139
+ }
140
+ } catch {
141
+ // Permission error or similar — ignore
142
+ }
143
+ }
144
+
145
+ // 4. Merge roles: deduplicate by roleType, attach claudeMd
146
+ const roles = deduplicateRoles(sessionRoles, roleClaudes);
147
+
148
+ // 5. Kanban
149
+ const kanban = readFileOrNull(join(crewDir, 'context', 'kanban.md')) || '';
150
+
151
+ // 6. Feature files from context/features/*.md
152
+ const features = [];
153
+ const featuresDir = join(crewDir, 'context', 'features');
154
+ if (existsSync(featuresDir)) {
155
+ try {
156
+ const files = readdirSync(featuresDir)
157
+ .filter(f => f.endsWith('.md') && f !== 'index.md')
158
+ .sort();
159
+ for (const f of files) {
160
+ const content = readFileOrNull(join(featuresDir, f));
161
+ if (content) {
162
+ features.push({ name: f.replace('.md', ''), content });
163
+ }
164
+ }
165
+ } catch {
166
+ // ignore
167
+ }
168
+ }
169
+
170
+ return { sharedClaudeMd, roles, kanban, features, teamType, language, sessionFeatures };
171
+ }
172
+
173
+ /**
174
+ * Deduplicate Crew roles by roleType.
175
+ * Crew may have dev-1, dev-2, dev-3 — collapse to a single "dev" for RolePlay.
176
+ * Attaches per-role CLAUDE.md content.
177
+ */
178
+ function deduplicateRoles(sessionRoles, roleClaudes) {
179
+ const byType = new Map(); // roleType -> first role seen
180
+ const merged = [];
181
+
182
+ for (const r of sessionRoles) {
183
+ const type = r.roleType || r.name;
184
+ const claudeMd = roleClaudes[r.name] || '';
185
+
186
+ if (byType.has(type)) {
187
+ // Already have this roleType — skip duplicate instance
188
+ continue;
189
+ }
190
+
191
+ byType.set(type, true);
192
+
193
+ // Use roleType as the RolePlay name (e.g. "developer" instead of "dev-1")
194
+ // But keep "pm" and "designer" as-is since they're typically single-instance
195
+ const name = type;
196
+ // Strip instance suffix from displayName (e.g. "开发者-托瓦兹-1" → "开发者-托瓦兹")
197
+ let displayName = r.displayName || name;
198
+ displayName = displayName.replace(/-\d+$/, '');
199
+
200
+ merged.push({
201
+ name,
202
+ displayName,
203
+ icon: r.icon || '',
204
+ description: r.description || '',
205
+ claudeMd: claudeMd.substring(0, MAX_CLAUDE_MD_LEN),
206
+ roleType: type,
207
+ isDecisionMaker: !!r.isDecisionMaker,
208
+ });
209
+ }
210
+
211
+ return merged;
212
+ }
213
+
68
214
  // ---------------------------------------------------------------------------
69
215
  // Input validation
70
216
  // ---------------------------------------------------------------------------
@@ -160,11 +306,11 @@ export function validateRolePlayConfig(config) {
160
306
  * Build the appendSystemPrompt that tells Claude about the role play roles
161
307
  * and how to switch between them.
162
308
  *
163
- * @param {{ roles: Array, teamType: string, language: string }} config
309
+ * @param {{ roles: Array, teamType: string, language: string, crewContext?: object }} config
164
310
  * @returns {string}
165
311
  */
166
312
  export function buildRolePlaySystemPrompt(config) {
167
- const { roles, teamType, language } = config;
313
+ const { roles, teamType, language, crewContext } = config;
168
314
  const isZh = language === 'zh-CN';
169
315
 
170
316
  // Build role list
@@ -181,6 +327,14 @@ export function buildRolePlaySystemPrompt(config) {
181
327
  ? buildZhPrompt(roleList, workflow)
182
328
  : buildEnPrompt(roleList, workflow);
183
329
 
330
+ // Append .crew context if available
331
+ if (crewContext) {
332
+ const contextBlock = buildCrewContextBlock(crewContext, isZh);
333
+ if (contextBlock) {
334
+ return (prompt + '\n\n' + contextBlock).trim();
335
+ }
336
+ }
337
+
184
338
  return prompt.trim();
185
339
  }
186
340
 
@@ -196,17 +350,37 @@ ${roleList}
196
350
 
197
351
  ## 角色切换规则
198
352
 
199
- 切换角色时,输出以下信号(必须独占一行,前后不能有其他内容):
353
+ ### 方式一:ROUTE 协议(推荐)
354
+
355
+ 当一个角色完成工作需要交给另一个角色时,使用 ROUTE 块:
356
+
357
+ \`\`\`
358
+ ---ROUTE---
359
+ to: {目标角色name}
360
+ summary: {交接内容摘要}
361
+ task: {任务ID,如 task-1}(可选)
362
+ taskTitle: {任务标题}(可选)
363
+ ---END_ROUTE---
364
+ \`\`\`
365
+
366
+ ROUTE 规则:
367
+ - 一次可以输出多个 ROUTE 块(例如同时发给 reviewer 和 tester)
368
+ - \`to\` 必须是有效的角色 name,或 \`human\` 表示需要用户输入
369
+ - \`summary\` 是交给目标角色的具体任务和上下文
370
+ - \`task\` / \`taskTitle\` 用于追踪 feature/任务(PM 分配任务时应填写)
371
+ - ROUTE 块必须在角色输出的末尾
372
+
373
+ ### 方式二:ROLE 信号(简单切换)
200
374
 
201
375
  ---ROLE: {角色name}---
202
376
 
203
- 切换后,你必须完全以该角色的视角和人格思考、说话和行动。不要混用其他角色的口吻。
377
+ 直接切换到目标角色继续工作,适用于简单的角色轮转。
204
378
 
205
- **重要**:
379
+ ### 通用规则
380
+
381
+ - 切换后,你必须完全以该角色的视角和人格思考、说话和行动
206
382
  - 第一条输出必须先切换到起始角色(通常是 PM)
207
- - 每次切换时先用 1-2 句话说明为什么要切换(作为上一个角色的收尾)
208
- - 切换后立即以新角色身份开始工作
209
- - 信号格式必须严格匹配:三个短横线 + ROLE: + 空格 + 角色name + 三个短横线
383
+ - 每次切换前留下交接信息(完成了什么、对下一角色的要求)
210
384
 
211
385
  ## 工作流程
212
386
 
@@ -243,17 +417,37 @@ ${roleList}
243
417
 
244
418
  ## Role Switching Rules
245
419
 
246
- When switching roles, output the following signal (must be on its own line, nothing else before or after):
420
+ ### Method 1: ROUTE Protocol (Recommended)
421
+
422
+ When a role finishes work and needs to hand off to another role, use a ROUTE block:
423
+
424
+ \`\`\`
425
+ ---ROUTE---
426
+ to: {target_role_name}
427
+ summary: {handoff content summary}
428
+ task: {task ID, e.g. task-1} (optional)
429
+ taskTitle: {task title} (optional)
430
+ ---END_ROUTE---
431
+ \`\`\`
432
+
433
+ ROUTE rules:
434
+ - You can output multiple ROUTE blocks at once (e.g., send to both reviewer and tester)
435
+ - \`to\` must be a valid role name, or \`human\` to request user input
436
+ - \`summary\` is the specific task and context for the target role
437
+ - \`task\` / \`taskTitle\` are for tracking features/tasks (PM should fill these when assigning)
438
+ - ROUTE blocks must be at the end of the role's output
439
+
440
+ ### Method 2: ROLE Signal (Simple Switch)
247
441
 
248
442
  ---ROLE: {role_name}---
249
443
 
250
- After switching, you must fully think, speak, and act from that role's perspective and personality. Do not mix tones from other roles.
444
+ Directly switch to the target role to continue working. Suitable for simple role rotation.
251
445
 
252
- **Important**:
446
+ ### General Rules
447
+
448
+ - After switching, you must fully think, speak, and act from that role's perspective
253
449
  - Your first output must switch to the starting role (usually PM)
254
- - Before each switch, briefly explain why you're switching (as closure for the current role)
255
- - After switching, immediately begin working as the new role
256
- - Signal format must strictly match: three hyphens + ROLE: + space + role_name + three hyphens
450
+ - Before each switch, leave handoff information (what was done, requirements for next role)
257
451
 
258
452
  ## Workflow
259
453
 
@@ -320,3 +514,205 @@ function buildDevWorkflow(roleNames, isZh) {
320
514
 
321
515
  return steps.join('\n');
322
516
  }
517
+
518
+ // ---------------------------------------------------------------------------
519
+ // RolePlay ROUTE protocol support
520
+ // ---------------------------------------------------------------------------
521
+
522
+ // Re-export parseRoutes for use by claude.js and tests
523
+ export { parseRoutes } from './crew/routing.js';
524
+
525
+ /**
526
+ * Initialize RolePlay route state on a session.
527
+ * Called when a roleplay conversation is first created or resumed.
528
+ *
529
+ * @param {object} session - rolePlaySessions entry
530
+ * @param {object} convState - ctx.conversations entry
531
+ */
532
+ export function initRolePlayRouteState(session, convState) {
533
+ if (!session._routeInitialized) {
534
+ session.currentRole = null;
535
+ session.features = new Map();
536
+ session.round = 0;
537
+ session.roleStates = {};
538
+ session.waitingHuman = false;
539
+ session.waitingHumanContext = null;
540
+
541
+ // Initialize per-role states
542
+ for (const role of session.roles) {
543
+ session.roleStates[role.name] = {
544
+ currentTask: null,
545
+ status: 'idle'
546
+ };
547
+ }
548
+ session._routeInitialized = true;
549
+ }
550
+
551
+ // Also store accumulated text on convState for ROUTE detection during streaming
552
+ if (!convState._roleplayAccumulated) {
553
+ convState._roleplayAccumulated = '';
554
+ }
555
+ }
556
+
557
+ /**
558
+ * Detect a ROLE signal in text: ---ROLE: xxx---
559
+ * Returns the role name if found at the end of accumulated text, null otherwise.
560
+ */
561
+ export function detectRoleSignal(text) {
562
+ const match = text.match(/---ROLE:\s*([a-zA-Z0-9_-]+)\s*---/);
563
+ return match ? match[1].toLowerCase() : null;
564
+ }
565
+
566
+ /**
567
+ * Process ROUTE blocks detected in a completed turn's output.
568
+ * Called from claude.js when a result message is received.
569
+ *
570
+ * Returns { routes, hasHumanRoute, continueRoles } for the caller to act on.
571
+ *
572
+ * @param {string} accumulatedText - full text output from the current turn
573
+ * @param {object} session - rolePlaySessions entry
574
+ * @returns {{ routes: Array, hasHumanRoute: boolean, continueRoles: Array<{to, prompt}> }}
575
+ */
576
+ export function processRolePlayRoutes(accumulatedText, session) {
577
+ const routes = parseRoutes(accumulatedText);
578
+ if (routes.length === 0) {
579
+ return { routes: [], hasHumanRoute: false, continueRoles: [] };
580
+ }
581
+
582
+ const roleNames = new Set(session.roles.map(r => r.name));
583
+ let hasHumanRoute = false;
584
+ const continueRoles = [];
585
+
586
+ for (const route of routes) {
587
+ const { to, summary, taskId, taskTitle } = route;
588
+
589
+ // Track features
590
+ if (taskId && taskTitle && !session.features.has(taskId)) {
591
+ session.features.set(taskId, { taskId, taskTitle, createdAt: Date.now() });
592
+ }
593
+
594
+ // Update source role state
595
+ if (session.currentRole && session.roleStates[session.currentRole]) {
596
+ session.roleStates[session.currentRole].status = 'idle';
597
+ }
598
+
599
+ if (to === 'human') {
600
+ hasHumanRoute = true;
601
+ session.waitingHuman = true;
602
+ session.waitingHumanContext = {
603
+ fromRole: session.currentRole,
604
+ reason: 'requested',
605
+ message: summary
606
+ };
607
+ } else if (roleNames.has(to)) {
608
+ // Update target role state
609
+ if (session.roleStates[to]) {
610
+ session.roleStates[to].status = 'active';
611
+ if (taskId) {
612
+ session.roleStates[to].currentTask = { taskId, taskTitle };
613
+ }
614
+ }
615
+
616
+ // Build prompt for the target role
617
+ const fromRole = session.currentRole || 'unknown';
618
+ const fromRoleConfig = session.roles.find(r => r.name === fromRole);
619
+ const fromLabel = fromRoleConfig
620
+ ? (fromRoleConfig.icon ? `${fromRoleConfig.icon} ${fromRoleConfig.displayName}` : fromRoleConfig.displayName)
621
+ : fromRole;
622
+
623
+ const targetRoleConfig = session.roles.find(r => r.name === to);
624
+ const targetClaudeMd = targetRoleConfig?.claudeMd || '';
625
+
626
+ let prompt = `来自 ${fromLabel} 的消息:\n${summary}\n\n`;
627
+ if (targetClaudeMd) {
628
+ prompt += `---\n<role-context>\n${targetClaudeMd}\n</role-context>\n\n`;
629
+ }
630
+ prompt += `你现在是 ${targetRoleConfig?.displayName || to}。请开始你的工作。完成后通过 ROUTE 块传递给下一个角色。`;
631
+
632
+ continueRoles.push({ to, prompt, taskId, taskTitle });
633
+ } else {
634
+ console.warn(`[RolePlay] Unknown route target: ${to}`);
635
+ }
636
+ }
637
+
638
+ // Increment round
639
+ session.round++;
640
+
641
+ return { routes, hasHumanRoute, continueRoles };
642
+ }
643
+
644
+ /**
645
+ * Build the route event message to send to the frontend via WebSocket.
646
+ *
647
+ * @param {string} conversationId
648
+ * @param {string} fromRole
649
+ * @param {{ to, summary, taskId, taskTitle }} route
650
+ * @returns {object} WebSocket message
651
+ */
652
+ export function buildRouteEventMessage(conversationId, fromRole, route) {
653
+ return {
654
+ type: 'roleplay_route',
655
+ conversationId,
656
+ from: fromRole,
657
+ to: route.to,
658
+ taskId: route.taskId || null,
659
+ taskTitle: route.taskTitle || null,
660
+ summary: route.summary || ''
661
+ };
662
+ }
663
+
664
+ /**
665
+ * Get the current RolePlay route state summary for frontend status updates.
666
+ *
667
+ * @param {string} conversationId
668
+ * @returns {object|null} Route state summary or null if not a roleplay session
669
+ */
670
+ export function getRolePlayRouteState(conversationId) {
671
+ const session = rolePlaySessions.get(conversationId);
672
+ if (!session || !session._routeInitialized) return null;
673
+
674
+ return {
675
+ currentRole: session.currentRole,
676
+ round: session.round,
677
+ features: session.features ? Array.from(session.features.values()) : [],
678
+ roleStates: session.roleStates || {},
679
+ waitingHuman: session.waitingHuman || false,
680
+ waitingHumanContext: session.waitingHumanContext || null
681
+ };
682
+ }
683
+
684
+ // ---------------------------------------------------------------------------
685
+ // .crew context block for system prompt
686
+ // ---------------------------------------------------------------------------
687
+
688
+ /**
689
+ * Build the .crew context block to append to the system prompt.
690
+ * Includes shared instructions, kanban, and feature history.
691
+ */
692
+ function buildCrewContextBlock(crewContext, isZh) {
693
+ const sections = [];
694
+
695
+ if (crewContext.sharedClaudeMd) {
696
+ const header = isZh ? '## 项目共享指令(来自 .crew)' : '## Shared Project Instructions (from .crew)';
697
+ sections.push(`${header}\n\n${crewContext.sharedClaudeMd}`);
698
+ }
699
+
700
+ if (crewContext.kanban) {
701
+ const header = isZh ? '## 当前任务看板' : '## Current Task Board';
702
+ sections.push(`${header}\n\n${crewContext.kanban}`);
703
+ }
704
+
705
+ if (crewContext.features && crewContext.features.length > 0) {
706
+ const header = isZh ? '## 历史工作记录' : '## Work History';
707
+ // Only include the last few features to avoid blowing up context
708
+ const recentFeatures = crewContext.features.slice(-5);
709
+ const featureTexts = recentFeatures.map(f => {
710
+ // Truncate each feature to keep total size reasonable
711
+ const content = f.content.length > 2000 ? f.content.substring(0, 2000) + '\n...(truncated)' : f.content;
712
+ return `### ${f.name}\n${content}`;
713
+ }).join('\n\n');
714
+ sections.push(`${header}\n\n${featureTexts}`);
715
+ }
716
+
717
+ return sections.join('\n\n');
718
+ }