@yeaft/webchat-agent 0.1.51 → 0.1.55

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,6 +1,7 @@
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 { buildVCrewSystemPrompt } from './vcrew.js';
4
5
 
5
6
  /**
6
7
  * Start a Claude SDK query for a conversation
@@ -9,11 +10,13 @@ import { sendConversationList, sendOutput, sendError, handleAskUserQuestion } fr
9
10
  export async function startClaudeQuery(conversationId, workDir, resumeSessionId) {
10
11
  // 如果已存在,先保存 per-session 设置,再关闭
11
12
  let savedDisallowedTools = null;
13
+ let savedVcrewConfig = null;
12
14
  let savedUserId = undefined;
13
15
  let savedUsername = undefined;
14
16
  if (ctx.conversations.has(conversationId)) {
15
17
  const existing = ctx.conversations.get(conversationId);
16
18
  savedDisallowedTools = existing.disallowedTools ?? null;
19
+ savedVcrewConfig = existing.vcrewConfig ?? null;
17
20
  savedUserId = existing.userId;
18
21
  savedUsername = existing.username;
19
22
  if (existing.abortController) {
@@ -51,6 +54,8 @@ export async function startClaudeQuery(conversationId, workDir, resumeSessionId)
51
54
  backgroundTasks: new Map(),
52
55
  // Per-session 工具禁用设置
53
56
  disallowedTools: savedDisallowedTools,
57
+ // Virtual Crew config (for appendSystemPrompt injection)
58
+ vcrewConfig: savedVcrewConfig,
54
59
  // 保留用户信息(从旧 state 恢复)
55
60
  userId: savedUserId,
56
61
  username: savedUsername,
@@ -80,6 +85,12 @@ export async function startClaudeQuery(conversationId, workDir, resumeSessionId)
80
85
  console.log(`[SDK] Disallowed tools: ${effectiveDisallowedTools.join(', ')}`);
81
86
  }
82
87
 
88
+ // Virtual Crew: inject appendSystemPrompt with role descriptions and workflow
89
+ if (savedVcrewConfig) {
90
+ options.appendSystemPrompt = buildVCrewSystemPrompt(savedVcrewConfig);
91
+ console.log(`[SDK] VCrew appendSystemPrompt injected (teamType: ${savedVcrewConfig.teamType})`);
92
+ }
93
+
83
94
  // Validate session ID is a valid UUID before using it
84
95
  const isValidUUID = (id) => {
85
96
  if (!id) return false;
package/conversation.js CHANGED
@@ -2,6 +2,10 @@ 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 { vcrewSessions, saveVCrewIndex, removeVCrewSession, loadVCrewIndex, validateVCrewConfig } from './vcrew.js';
6
+
7
+ // Restore persisted vcrew sessions on module load (agent startup)
8
+ loadVCrewIndex();
5
9
 
6
10
  // 不支持的斜杠命令(真正需要交互式 CLI 的命令)
7
11
  const UNSUPPORTED_SLASH_COMMANDS = ['/help', '/bug', '/login', '/logout', '/terminal-setup', '/vim', '/config'];
@@ -34,11 +38,11 @@ export function parseSlashCommand(message) {
34
38
  return { type: null, message };
35
39
  }
36
40
 
37
- // 发送 conversation 列表(含活跃 crew sessions + 索引中已停止的 crew sessions)
41
+ // 发送 conversation 列表(含活跃 crew sessions + 索引中已停止的 crew sessions + vcrew sessions
38
42
  export async function sendConversationList() {
39
43
  const list = [];
40
44
  for (const [id, state] of ctx.conversations) {
41
- list.push({
45
+ const entry = {
42
46
  id,
43
47
  workDir: state.workDir,
44
48
  claudeSessionId: state.claudeSessionId,
@@ -46,7 +50,13 @@ export async function sendConversationList() {
46
50
  processing: !!state.turnActive,
47
51
  userId: state.userId,
48
52
  username: state.username
49
- });
53
+ };
54
+ // vcrew conversations are stored in ctx.conversations but also tracked in vcrewSessions
55
+ if (vcrewSessions.has(id)) {
56
+ entry.type = 'virtualCrew';
57
+ entry.vcrewRoles = vcrewSessions.get(id).roles;
58
+ }
59
+ list.push(entry);
50
60
  }
51
61
  // 追加活跃 crew sessions
52
62
  const activeCrewIds = new Set();
@@ -108,8 +118,21 @@ export async function createConversation(msg) {
108
118
  const { conversationId, workDir, userId, username, disallowedTools } = msg;
109
119
  const effectiveWorkDir = workDir || ctx.CONFIG.workDir;
110
120
 
121
+ // Validate and sanitize vcrewConfig if provided
122
+ let vcrewConfig = null;
123
+ if (msg.vcrewConfig) {
124
+ const result = validateVCrewConfig(msg.vcrewConfig);
125
+ if (!result.valid) {
126
+ console.warn(`[createConversation] Invalid vcrewConfig: ${result.error}`);
127
+ sendError(conversationId, `Invalid vcrewConfig: ${result.error}`);
128
+ return;
129
+ }
130
+ vcrewConfig = result.config;
131
+ }
132
+
111
133
  console.log(`Creating conversation: ${conversationId} in ${effectiveWorkDir} (lazy start)`);
112
134
  if (username) console.log(` User: ${username} (${userId})`);
135
+ if (vcrewConfig) console.log(` VCrew: teamType=${vcrewConfig.teamType}, roles=${vcrewConfig.roles?.length}`);
113
136
 
114
137
  // 只创建 conversation 状态,不启动 Claude 进程
115
138
  // Claude 进程会在用户发送第一条消息时启动 (见 handleUserInput)
@@ -126,6 +149,7 @@ export async function createConversation(msg) {
126
149
  userId,
127
150
  username,
128
151
  disallowedTools: disallowedTools || null, // null = 使用全局默认
152
+ vcrewConfig: vcrewConfig || null,
129
153
  usage: {
130
154
  inputTokens: 0,
131
155
  outputTokens: 0,
@@ -135,13 +159,28 @@ export async function createConversation(msg) {
135
159
  }
136
160
  });
137
161
 
162
+ // Register in vcrewSessions for type inference in sendConversationList
163
+ if (vcrewConfig) {
164
+ vcrewSessions.set(conversationId, {
165
+ roles: vcrewConfig.roles,
166
+ teamType: vcrewConfig.teamType,
167
+ language: vcrewConfig.language,
168
+ projectDir: effectiveWorkDir,
169
+ createdAt: Date.now(),
170
+ userId,
171
+ username,
172
+ });
173
+ saveVCrewIndex();
174
+ }
175
+
138
176
  ctx.sendToServer({
139
177
  type: 'conversation_created',
140
178
  conversationId,
141
179
  workDir: effectiveWorkDir,
142
180
  userId,
143
181
  username,
144
- disallowedTools: disallowedTools || null
182
+ disallowedTools: disallowedTools || null,
183
+ vcrewConfig: vcrewConfig || null
145
184
  });
146
185
 
147
186
  // 立即发送 agent 级别的 MCP servers 列表(从 ~/.claude.json 读取的)
@@ -193,6 +232,12 @@ export async function resumeConversation(msg) {
193
232
 
194
233
  // 只创建 conversation 状态并保存 claudeSessionId,不启动 Claude 进程
195
234
  // Claude 进程会在用户发送第一条消息时启动 (见 handleUserInput)
235
+ // Restore vcrewConfig from persisted vcrewSessions if available
236
+ const vcrewEntry = vcrewSessions.get(conversationId);
237
+ const vcrewConfig = vcrewEntry
238
+ ? { roles: vcrewEntry.roles, teamType: vcrewEntry.teamType, language: vcrewEntry.language }
239
+ : null;
240
+
196
241
  ctx.conversations.set(conversationId, {
197
242
  query: null,
198
243
  inputStream: null,
@@ -206,6 +251,7 @@ export async function resumeConversation(msg) {
206
251
  userId,
207
252
  username,
208
253
  disallowedTools: disallowedTools || null, // null = 使用全局默认
254
+ vcrewConfig,
209
255
  usage: {
210
256
  inputTokens: 0,
211
257
  outputTokens: 0,
@@ -271,6 +317,11 @@ export function deleteConversation(msg) {
271
317
  ctx.conversations.delete(conversationId);
272
318
  }
273
319
 
320
+ // Clean up vcrew session if applicable
321
+ if (vcrewSessions.has(conversationId)) {
322
+ removeVCrewSession(conversationId);
323
+ }
324
+
274
325
  ctx.sendToServer({
275
326
  type: 'conversation_deleted',
276
327
  conversationId
package/crew-i18n.js CHANGED
@@ -129,6 +129,16 @@ ${isDevTeam ? '3' : '2'}. **任务完成** - 所有任务已完成,给出完
129
129
  止损触发时立即执行并通知决策者。遇到无法执行的情况立即反馈。`,
130
130
  strategist: () => '', // DM
131
131
 
132
+ // dev team
133
+ developer: (dm) => `代码完成后,必须同时发两个 ROUTE 块分别交给审查者和测试者(缺一不可)。
134
+ UI/交互方案不确定时找设计师确认。需求不明确时找决策者 "${dm}" 确认。`,
135
+ reviewer_dev: (dm) => `审查完成后:评分 ≥ 9分通过,ROUTE 给决策者 "${dm}" 报告通过。评分 < 9分打回给开发者修改。
136
+ 遇到架构层面的问题找决策者 "${dm}" 讨论。`,
137
+ tester_dev: (dm) => `测试完成后:全部通过则 ROUTE 给决策者 "${dm}" 报告通过。发现 bug 则编写复现测试,ROUTE 给开发者修复。
138
+ 遇到测试环境问题找决策者 "${dm}" 协调。`,
139
+ designer_dev: (dm) => `设计完成后交给决策者 "${dm}" 审阅,通过后交给开发者实现。
140
+ 收到开发者的 UI 问题反馈时评估并调整设计方案。需求不明确找决策者 "${dm}" 确认。`,
141
+
132
142
  // video team
133
143
  scriptwriter: (dm) => `脚本完成后,建议 ROUTE 给决策者 "${dm}" 审核。
134
144
  收到修改意见后调整脚本重新提交。叙事方向不确定时找决策者确认。`,
@@ -361,6 +371,16 @@ Continuously monitor existing positions and proactively alert on anomalies.`,
361
371
  Execute stop-losses immediately when triggered and notify the decision maker. Report any execution difficulties immediately.`,
362
372
  strategist: () => '',
363
373
 
374
+ // dev team
375
+ developer: (dm) => `After code is complete, you must send two ROUTE blocks simultaneously to reviewer and tester (both required).
376
+ Check with designer for UI/interaction questions. Check with decision maker "${dm}" for unclear requirements.`,
377
+ reviewer_dev: (dm) => `After review: score >= 9 passes, ROUTE to decision maker "${dm}" to report approval. Score < 9: send back to developer with issues.
378
+ Discuss architecture-level concerns with decision maker "${dm}".`,
379
+ tester_dev: (dm) => `After testing: all pass, ROUTE to decision maker "${dm}" to report approval. Bug found: write reproduction test, ROUTE to developer for fixing.
380
+ Coordinate test environment issues with decision maker "${dm}".`,
381
+ designer_dev: (dm) => `After design is complete, hand to decision maker "${dm}" for review, then to developer for implementation.
382
+ Evaluate and adjust design based on developer UI feedback. Check with decision maker "${dm}" for unclear requirements.`,
383
+
364
384
  // video team
365
385
  scriptwriter: (dm) => `After completing the script, consider ROUTEing to the decision maker "${dm}" for review.
366
386
  Adjust and resubmit after receiving revision notes. Check with the decision maker when narrative direction is uncertain.`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "0.1.51",
3
+ "version": "0.1.55",
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/vcrew.js ADDED
@@ -0,0 +1,305 @@
1
+ /**
2
+ * Virtual Crew — lightweight multi-role collaboration within a single conversation.
3
+ *
4
+ * Manages vcrewSessions (in-memory + persisted to disk) and builds the
5
+ * appendSystemPrompt that instructs Claude to role-play multiple characters.
6
+ */
7
+
8
+ import { join } from 'path';
9
+ import { readFileSync, writeFileSync, existsSync } from 'fs';
10
+
11
+ const VCREW_INDEX_PATH = join(process.env.HOME, '.claude', 'vcrew-sessions.json');
12
+
13
+ // In-memory map: conversationId -> { roles, teamType, language, projectDir, createdAt, userId, username }
14
+ export const vcrewSessions = new Map();
15
+
16
+ // ---------------------------------------------------------------------------
17
+ // Persistence
18
+ // ---------------------------------------------------------------------------
19
+
20
+ export function saveVCrewIndex() {
21
+ const data = [];
22
+ for (const [id, session] of vcrewSessions) {
23
+ data.push({ id, ...session });
24
+ }
25
+ try {
26
+ writeFileSync(VCREW_INDEX_PATH, JSON.stringify(data, null, 2));
27
+ } catch (e) {
28
+ console.warn('[vcrew] Failed to save index:', e.message);
29
+ }
30
+ }
31
+
32
+ export function loadVCrewIndex() {
33
+ if (!existsSync(VCREW_INDEX_PATH)) return;
34
+ try {
35
+ const data = JSON.parse(readFileSync(VCREW_INDEX_PATH, 'utf-8'));
36
+ for (const entry of data) {
37
+ const { id, ...session } = entry;
38
+ vcrewSessions.set(id, session);
39
+ }
40
+ console.log(`[vcrew] Loaded ${vcrewSessions.size} sessions from index`);
41
+ } catch (e) {
42
+ console.warn('[vcrew] Failed to load index:', e.message);
43
+ }
44
+ }
45
+
46
+ export function removeVCrewSession(conversationId) {
47
+ vcrewSessions.delete(conversationId);
48
+ saveVCrewIndex();
49
+ }
50
+
51
+ // ---------------------------------------------------------------------------
52
+ // Input validation
53
+ // ---------------------------------------------------------------------------
54
+
55
+ const ALLOWED_TEAM_TYPES = ['dev', 'writing', 'trading', 'video', 'custom'];
56
+ const MAX_ROLE_NAME_LEN = 64;
57
+ const MAX_DISPLAY_NAME_LEN = 128;
58
+ const MAX_CLAUDE_MD_LEN = 4096;
59
+ const MAX_ROLES = 10;
60
+
61
+ /**
62
+ * Validate and sanitize vcrewConfig from the client.
63
+ * Returns { valid: true, config: sanitizedConfig } or { valid: false, error: string }.
64
+ *
65
+ * @param {*} config - raw vcrewConfig from client message
66
+ * @returns {{ valid: boolean, config?: object, error?: string }}
67
+ */
68
+ export function validateVCrewConfig(config) {
69
+ if (!config || typeof config !== 'object') {
70
+ return { valid: false, error: 'vcrewConfig must be an object' };
71
+ }
72
+
73
+ // teamType
74
+ const teamType = config.teamType;
75
+ if (typeof teamType !== 'string' || !ALLOWED_TEAM_TYPES.includes(teamType)) {
76
+ return { valid: false, error: `teamType must be one of: ${ALLOWED_TEAM_TYPES.join(', ')}` };
77
+ }
78
+
79
+ // language
80
+ const language = config.language;
81
+ if (language !== 'zh-CN' && language !== 'en') {
82
+ return { valid: false, error: 'language must be "zh-CN" or "en"' };
83
+ }
84
+
85
+ // roles
86
+ if (!Array.isArray(config.roles) || config.roles.length === 0) {
87
+ return { valid: false, error: 'roles must be a non-empty array' };
88
+ }
89
+ if (config.roles.length > MAX_ROLES) {
90
+ return { valid: false, error: `roles must have at most ${MAX_ROLES} entries` };
91
+ }
92
+
93
+ const sanitizedRoles = [];
94
+ const seenNames = new Set();
95
+
96
+ for (let i = 0; i < config.roles.length; i++) {
97
+ const r = config.roles[i];
98
+ if (!r || typeof r !== 'object') {
99
+ return { valid: false, error: `roles[${i}] must be an object` };
100
+ }
101
+
102
+ // name: required string, alphanumeric + hyphens/underscores
103
+ if (typeof r.name !== 'string' || r.name.length === 0 || r.name.length > MAX_ROLE_NAME_LEN) {
104
+ return { valid: false, error: `roles[${i}].name must be a non-empty string (max ${MAX_ROLE_NAME_LEN} chars)` };
105
+ }
106
+ if (!/^[a-zA-Z0-9_-]+$/.test(r.name)) {
107
+ return { valid: false, error: `roles[${i}].name must contain only alphanumeric, hyphens, or underscores` };
108
+ }
109
+ if (seenNames.has(r.name)) {
110
+ return { valid: false, error: `roles[${i}].name "${r.name}" is duplicated` };
111
+ }
112
+ seenNames.add(r.name);
113
+
114
+ // displayName: required string
115
+ if (typeof r.displayName !== 'string' || r.displayName.length === 0 || r.displayName.length > MAX_DISPLAY_NAME_LEN) {
116
+ return { valid: false, error: `roles[${i}].displayName must be a non-empty string (max ${MAX_DISPLAY_NAME_LEN} chars)` };
117
+ }
118
+
119
+ sanitizedRoles.push({
120
+ name: r.name,
121
+ displayName: r.displayName.substring(0, MAX_DISPLAY_NAME_LEN),
122
+ icon: typeof r.icon === 'string' ? r.icon.substring(0, 8) : '',
123
+ description: typeof r.description === 'string' ? r.description.substring(0, 500) : '',
124
+ claudeMd: typeof r.claudeMd === 'string' ? r.claudeMd.substring(0, MAX_CLAUDE_MD_LEN) : '',
125
+ });
126
+ }
127
+
128
+ return {
129
+ valid: true,
130
+ config: {
131
+ roles: sanitizedRoles,
132
+ teamType,
133
+ language,
134
+ }
135
+ };
136
+ }
137
+
138
+ // ---------------------------------------------------------------------------
139
+ // System prompt construction
140
+ // ---------------------------------------------------------------------------
141
+
142
+ /**
143
+ * Build the appendSystemPrompt that tells Claude about the virtual crew roles
144
+ * and how to switch between them.
145
+ *
146
+ * @param {{ roles: Array, teamType: string, language: string }} config
147
+ * @returns {string}
148
+ */
149
+ export function buildVCrewSystemPrompt(config) {
150
+ const { roles, teamType, language } = config;
151
+ const isZh = language === 'zh-CN';
152
+
153
+ // Build role list
154
+ const roleList = roles.map(r => {
155
+ const desc = r.claudeMd || r.description || '';
156
+ const icon = r.icon ? `${r.icon} ` : '';
157
+ return `### ${icon}${r.displayName} (${r.name})\n${desc}`;
158
+ }).join('\n\n');
159
+
160
+ // Build workflow
161
+ const workflow = getWorkflow(teamType, roles, isZh);
162
+
163
+ const prompt = isZh
164
+ ? buildZhPrompt(roleList, workflow)
165
+ : buildEnPrompt(roleList, workflow);
166
+
167
+ return prompt.trim();
168
+ }
169
+
170
+ function buildZhPrompt(roleList, workflow) {
171
+ return `
172
+ # 多角色协作模式
173
+
174
+ 你正在以多角色协作方式完成任务。你将依次扮演不同角色,每个角色有独立的视角、人格和专业能力。
175
+
176
+ ## 可用角色
177
+
178
+ ${roleList}
179
+
180
+ ## 角色切换规则
181
+
182
+ 切换角色时,输出以下信号(必须独占一行,前后不能有其他内容):
183
+
184
+ ---ROLE: {角色name}---
185
+
186
+ 切换后,你必须完全以该角色的视角和人格思考、说话和行动。不要混用其他角色的口吻。
187
+
188
+ **重要**:
189
+ - 第一条输出必须先切换到起始角色(通常是 PM)
190
+ - 每次切换时先用 1-2 句话说明为什么要切换(作为上一个角色的收尾)
191
+ - 切换后立即以新角色身份开始工作
192
+ - 信号格式必须严格匹配:三个短横线 + ROLE: + 空格 + 角色name + 三个短横线
193
+
194
+ ## 工作流程
195
+
196
+ ${workflow}
197
+
198
+ ## 交接规范
199
+
200
+ 每次角色切换时,上一个角色必须留下清晰的交接信息:
201
+ - 完成了什么
202
+ - 未完成的部分(如有)
203
+ - 对下一个角色的具体要求或注意事项
204
+
205
+ ## 输出格式
206
+
207
+ - 代码修改使用工具(Read, Edit, Write 等),不要在聊天中贴大段代码
208
+ - 每个角色专注做自己的事,不要代替其他角色
209
+ - Review 和 Test 角色如果发现问题,必须切回 Dev 修复后再继续
210
+
211
+ ## 语言
212
+
213
+ 使用中文交流。代码注释可以用英文。
214
+ `;
215
+ }
216
+
217
+ function buildEnPrompt(roleList, workflow) {
218
+ return `
219
+ # Multi-Role Collaboration Mode
220
+
221
+ You are working in multi-role collaboration mode. You will take on different roles sequentially, each with its own perspective, personality, and expertise.
222
+
223
+ ## Available Roles
224
+
225
+ ${roleList}
226
+
227
+ ## Role Switching Rules
228
+
229
+ When switching roles, output the following signal (must be on its own line, nothing else before or after):
230
+
231
+ ---ROLE: {role_name}---
232
+
233
+ After switching, you must fully think, speak, and act from that role's perspective and personality. Do not mix tones from other roles.
234
+
235
+ **Important**:
236
+ - Your first output must switch to the starting role (usually PM)
237
+ - Before each switch, briefly explain why you're switching (as closure for the current role)
238
+ - After switching, immediately begin working as the new role
239
+ - Signal format must strictly match: three hyphens + ROLE: + space + role_name + three hyphens
240
+
241
+ ## Workflow
242
+
243
+ ${workflow}
244
+
245
+ ## Handoff Convention
246
+
247
+ Each role switch must include clear handoff information from the previous role:
248
+ - What was completed
249
+ - What remains (if any)
250
+ - Specific requirements or notes for the next role
251
+
252
+ ## Output Format
253
+
254
+ - Use tools (Read, Edit, Write, etc.) for code changes, don't paste large code blocks in chat
255
+ - Each role focuses on its own responsibility, don't do other roles' jobs
256
+ - If Review or Test finds issues, must switch back to Dev to fix before continuing
257
+
258
+ ## Language
259
+
260
+ Communicate in English. Code comments in English.
261
+ `;
262
+ }
263
+
264
+ // ---------------------------------------------------------------------------
265
+ // Workflow generation per team type
266
+ // ---------------------------------------------------------------------------
267
+
268
+ function getWorkflow(teamType, roles, isZh) {
269
+ const roleNames = roles.map(r => r.name);
270
+
271
+ if (teamType === 'dev') {
272
+ return buildDevWorkflow(roleNames, isZh);
273
+ }
274
+
275
+ // Generic fallback for other team types
276
+ return isZh ? '按角色顺序依次完成任务。' : 'Complete tasks by following the role sequence.';
277
+ }
278
+
279
+ function buildDevWorkflow(roleNames, isZh) {
280
+ const hasPm = roleNames.includes('pm');
281
+ const hasDev = roleNames.includes('dev');
282
+ const hasReviewer = roleNames.includes('reviewer');
283
+ const hasTester = roleNames.includes('tester');
284
+ const hasDesigner = roleNames.includes('designer');
285
+
286
+ const steps = [];
287
+
288
+ if (isZh) {
289
+ if (hasPm) steps.push(`${steps.length + 1}. **PM** 分析需求,拆分任务,确定验收标准`);
290
+ if (hasDesigner) steps.push(`${steps.length + 1}. **设计师** 确认交互方案(如涉及 UI)`);
291
+ if (hasDev) steps.push(`${steps.length + 1}. **开发者** 实现代码(使用工具读写文件)`);
292
+ if (hasReviewer) steps.push(`${steps.length + 1}. **审查者** Code Review(不通过 → 返回开发者修复)`);
293
+ if (hasTester) steps.push(`${steps.length + 1}. **测试者** 运行测试 & 验证(有 bug → 返回开发者修复)`);
294
+ if (hasPm) steps.push(`${steps.length + 1}. **PM** 验收总结`);
295
+ } else {
296
+ if (hasPm) steps.push(`${steps.length + 1}. **PM** analyzes requirements, breaks down tasks, defines acceptance criteria`);
297
+ if (hasDesigner) steps.push(`${steps.length + 1}. **Designer** confirms interaction design (if UI involved)`);
298
+ if (hasDev) steps.push(`${steps.length + 1}. **Dev** implements code (using tools to read/write files)`);
299
+ if (hasReviewer) steps.push(`${steps.length + 1}. **Reviewer** code review (if fails → back to Dev)`);
300
+ if (hasTester) steps.push(`${steps.length + 1}. **Tester** runs tests & verifies (if bugs → back to Dev)`);
301
+ if (hasPm) steps.push(`${steps.length + 1}. **PM** acceptance & summary`);
302
+ }
303
+
304
+ return steps.join('\n');
305
+ }