@yeaft/webchat-agent 1.0.79 → 1.0.81
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/connection/buffer.js +2 -6
- package/connection/message-router.js +1 -64
- package/conversation.js +4 -32
- package/history.js +2 -2
- package/index.js +0 -6
- package/package.json +1 -1
- package/providers/claude-code.js +1 -1
- package/yeaft/attachments.js +2 -2
- package/yeaft/config.js +1 -1
- package/yeaft/conversation/persist.js +458 -112
- package/yeaft/conversation/search.js +51 -15
- package/yeaft/session.js +27 -18
- package/yeaft/sessions/session-config.js +2 -0
- package/yeaft/sessions/session-crud.js +3 -2
- package/yeaft/web-bridge.js +27 -15
- package/crew/builtin-actions.js +0 -154
- package/crew/context-loader.js +0 -171
- package/crew/control.js +0 -444
- package/crew/human-interaction.js +0 -195
- package/crew/persistence.js +0 -295
- package/crew/role-management.js +0 -182
- package/crew/role-output.js +0 -461
- package/crew/role-query.js +0 -406
- package/crew/role-states.js +0 -180
- package/crew/routing-fallback.js +0 -64
- package/crew/routing-metrics.js +0 -215
- package/crew/routing.js +0 -951
- package/crew/session.js +0 -648
- package/crew/shared-dir.js +0 -266
- package/crew/task-files.js +0 -554
- package/crew/ui-messages.js +0 -274
- package/crew/worktree.js +0 -130
- package/crew-i18n.js +0 -579
- package/crew.js +0 -48
package/crew/role-query.js
DELETED
|
@@ -1,406 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Crew — 角色 Query 管理
|
|
3
|
-
* createRoleQuery, buildRoleSystemPrompt, sessionId 持久化, 错误分类
|
|
4
|
-
*/
|
|
5
|
-
import { query, Stream } from '../sdk/index.js';
|
|
6
|
-
import { promises as fs, mkdirSync } from 'fs';
|
|
7
|
-
import { join } from 'path';
|
|
8
|
-
import { getMessages } from '../crew-i18n.js';
|
|
9
|
-
import { handleAskUserQuestion } from '../conversation.js';
|
|
10
|
-
import ctx from '../context.js';
|
|
11
|
-
|
|
12
|
-
/** Format role label */
|
|
13
|
-
function roleLabel(r) {
|
|
14
|
-
return r.icon ? `${r.icon} ${r.displayName}` : r.displayName;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
// =====================================================================
|
|
18
|
-
// Session Persistence (role sessionId)
|
|
19
|
-
// =====================================================================
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* 保存角色的 claudeSessionId 到文件
|
|
23
|
-
*/
|
|
24
|
-
export async function saveRoleSessionId(sharedDir, roleName, claudeSessionId) {
|
|
25
|
-
const sessionsDir = join(sharedDir, 'sessions');
|
|
26
|
-
await fs.mkdir(sessionsDir, { recursive: true });
|
|
27
|
-
const filePath = join(sessionsDir, `${roleName}.json`);
|
|
28
|
-
await fs.writeFile(filePath, JSON.stringify({
|
|
29
|
-
claudeSessionId,
|
|
30
|
-
savedAt: Date.now()
|
|
31
|
-
}));
|
|
32
|
-
console.log(`[Crew] Saved sessionId for ${roleName}: ${claudeSessionId}`);
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
/**
|
|
36
|
-
* 从文件加载角色的 claudeSessionId
|
|
37
|
-
*/
|
|
38
|
-
export async function loadRoleSessionId(sharedDir, roleName) {
|
|
39
|
-
const filePath = join(sharedDir, 'sessions', `${roleName}.json`);
|
|
40
|
-
try {
|
|
41
|
-
const data = JSON.parse(await fs.readFile(filePath, 'utf-8'));
|
|
42
|
-
return data.claudeSessionId || null;
|
|
43
|
-
} catch {
|
|
44
|
-
return null;
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
/**
|
|
49
|
-
* 清除角色的 savedSessionId(用于强制新建 conversation)
|
|
50
|
-
*/
|
|
51
|
-
export async function clearRoleSessionId(sharedDir, roleName) {
|
|
52
|
-
const filePath = join(sharedDir, 'sessions', `${roleName}.json`);
|
|
53
|
-
try {
|
|
54
|
-
await fs.unlink(filePath);
|
|
55
|
-
console.log(`[Crew] Cleared sessionId for ${roleName} (force new conversation)`);
|
|
56
|
-
} catch {
|
|
57
|
-
// 文件不存在也正常
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
/**
|
|
62
|
-
* 判断角色错误是否可恢复
|
|
63
|
-
*/
|
|
64
|
-
export function classifyRoleError(error) {
|
|
65
|
-
const msg = error.message || '';
|
|
66
|
-
if (/context.*(window|limit|exceeded)|token.*limit|too.*(long|large)|max.*token/i.test(msg)) {
|
|
67
|
-
return { recoverable: true, reason: 'context_exceeded' };
|
|
68
|
-
}
|
|
69
|
-
if (/compact|compress|context.*reduc/i.test(msg)) {
|
|
70
|
-
return { recoverable: true, reason: 'compact_failed' };
|
|
71
|
-
}
|
|
72
|
-
if (/rate.?limit|429|overloaded|503|502|timeout|ECONNRESET|ETIMEDOUT/i.test(msg)) {
|
|
73
|
-
return { recoverable: true, reason: 'transient_api_error' };
|
|
74
|
-
}
|
|
75
|
-
if (/exited with code [1-9]/i.test(msg) && msg.length < 100) {
|
|
76
|
-
return { recoverable: true, reason: 'process_crashed' };
|
|
77
|
-
}
|
|
78
|
-
if (/spawn|ENOENT|not found/i.test(msg)) {
|
|
79
|
-
return { recoverable: false, reason: 'spawn_failed' };
|
|
80
|
-
}
|
|
81
|
-
return { recoverable: true, reason: 'unknown' };
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
// =====================================================================
|
|
85
|
-
// Role Query Management
|
|
86
|
-
// =====================================================================
|
|
87
|
-
|
|
88
|
-
// P1-6: Per-role mutex to prevent concurrent createRoleQuery calls creating orphan processes
|
|
89
|
-
const _roleQueryLocks = new Map();
|
|
90
|
-
|
|
91
|
-
/**
|
|
92
|
-
* 为角色创建持久 query 实例
|
|
93
|
-
*/
|
|
94
|
-
export async function createRoleQuery(session, roleName) {
|
|
95
|
-
const lockKey = `${session.id}:${roleName}`;
|
|
96
|
-
|
|
97
|
-
// P1-6: 如果该角色已有 pending 的 createRoleQuery,等待它完成
|
|
98
|
-
if (_roleQueryLocks.has(lockKey)) {
|
|
99
|
-
console.log(`[Crew] Waiting for existing createRoleQuery lock on ${roleName}`);
|
|
100
|
-
try {
|
|
101
|
-
await _roleQueryLocks.get(lockKey);
|
|
102
|
-
} catch {
|
|
103
|
-
// Previous attempt failed, proceed with new creation
|
|
104
|
-
}
|
|
105
|
-
// 锁释放后,检查是否已经有可用的 query
|
|
106
|
-
const existing = session.roleStates.get(roleName);
|
|
107
|
-
if (existing?.query && existing?.inputStream && !existing.inputStream.isDone) {
|
|
108
|
-
console.log(`[Crew] Reusing existing query for ${roleName} after lock release`);
|
|
109
|
-
return existing;
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
const promise = _createRoleQueryInner(session, roleName);
|
|
114
|
-
_roleQueryLocks.set(lockKey, promise);
|
|
115
|
-
try {
|
|
116
|
-
const result = await promise;
|
|
117
|
-
return result;
|
|
118
|
-
} finally {
|
|
119
|
-
// 只删除自己的 lock(如果后续调用覆盖了,不误删)
|
|
120
|
-
if (_roleQueryLocks.get(lockKey) === promise) {
|
|
121
|
-
_roleQueryLocks.delete(lockKey);
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
/**
|
|
127
|
-
* createRoleQuery 内部实现
|
|
128
|
-
*/
|
|
129
|
-
async function _createRoleQueryInner(session, roleName) {
|
|
130
|
-
const role = session.roles.get(roleName);
|
|
131
|
-
if (!role) throw new Error(`Role not found: ${roleName}`);
|
|
132
|
-
|
|
133
|
-
// Lazy import to avoid circular dependency
|
|
134
|
-
const { processRoleOutput } = await import('./role-output.js');
|
|
135
|
-
|
|
136
|
-
const inputStream = new Stream();
|
|
137
|
-
const abortController = new AbortController();
|
|
138
|
-
|
|
139
|
-
const systemPrompt = buildRoleSystemPrompt(role, session);
|
|
140
|
-
|
|
141
|
-
// 尝试加载之前保存的 sessionId
|
|
142
|
-
const savedSessionId = await loadRoleSessionId(session.sharedDir, roleName);
|
|
143
|
-
|
|
144
|
-
// cwd 设为项目目录(确保 Claude CLI 能匹配项目级 MCP 配置)
|
|
145
|
-
// 角色的 CLAUDE.md 和工作路径已通过 appendSystemPrompt 注入
|
|
146
|
-
const roleCwd = session.projectDir || join(session.sharedDir, 'roles', roleName);
|
|
147
|
-
const roleDir = join(session.sharedDir, 'roles', roleName);
|
|
148
|
-
mkdirSync(roleDir, { recursive: true });
|
|
149
|
-
|
|
150
|
-
// 继承全局 MCP disallowedTools,避免不必要的 tool schema token 消耗
|
|
151
|
-
const globalDisallowed = ctx.CONFIG?.disallowedTools || [];
|
|
152
|
-
// Developer 角色保留 skills(tdd、commit 等对开发有帮助),
|
|
153
|
-
// 其他角色(pm、reviewer、product-reviewer)禁用 skills 以避免和 ROUTE 冲突
|
|
154
|
-
const isDeveloper = role.roleType === 'developer';
|
|
155
|
-
// Crew 角色禁用 Agent 工具:角色间协作必须通过 ROUTE 块,不能自行启动 sub-agent
|
|
156
|
-
// 非 developer 角色额外禁用 Skill 工具:skills 的 trigger 词会和角色职能冲突
|
|
157
|
-
// (如 review-code、commit),导致 Claude 调用 Skill 而不是输出 ROUTE 块
|
|
158
|
-
const crewDisallowed = isDeveloper ? ['Agent'] : ['Agent', 'Skill'];
|
|
159
|
-
const effectiveDisallowed = [...globalDisallowed, ...crewDisallowed];
|
|
160
|
-
|
|
161
|
-
const queryOptions = {
|
|
162
|
-
cwd: roleCwd,
|
|
163
|
-
// 不设 permissionMode(走 SDK 默认 'default')。bypassPermissions +
|
|
164
|
-
// canCallTool 在新版 Claude CLI 会卡死 init 握手 / message_start。
|
|
165
|
-
// canCallTool 在 default 模式下能正常工作,并对非 AskUserQuestion
|
|
166
|
-
// 工具返回 { behavior:'allow', updatedInput } 等同放行。
|
|
167
|
-
abort: abortController.signal,
|
|
168
|
-
model: role.model || undefined,
|
|
169
|
-
appendSystemPrompt: systemPrompt,
|
|
170
|
-
// 非 developer 角色禁用 skills(--disable-slash-commands),
|
|
171
|
-
// 从 CLI 层面阻止 skills 系统 prompt 注入,避免和 ROUTE 冲突
|
|
172
|
-
...(!isDeveloper && { disableSlashCommands: true }),
|
|
173
|
-
...(effectiveDisallowed.length > 0 && { disallowedTools: effectiveDisallowed })
|
|
174
|
-
};
|
|
175
|
-
|
|
176
|
-
// Intercept AskUserQuestion for all roles — forward to Web UI for interactive answering.
|
|
177
|
-
// Without this, non-DM roles' AskCard buttons stay disabled (no askRequestId).
|
|
178
|
-
// Note: Skill tool is fully disallowed via disallowedTools above, so no canCallTool guard needed.
|
|
179
|
-
queryOptions.canCallTool = async (toolName, input, toolCtx) => {
|
|
180
|
-
if (toolName === 'AskUserQuestion') {
|
|
181
|
-
return await handleAskUserQuestion(session.id, input, toolCtx);
|
|
182
|
-
}
|
|
183
|
-
return { behavior: 'allow', updatedInput: input };
|
|
184
|
-
};
|
|
185
|
-
|
|
186
|
-
if (savedSessionId) {
|
|
187
|
-
queryOptions.resume = savedSessionId;
|
|
188
|
-
console.log(`[Crew] Resuming ${roleName} with sessionId: ${savedSessionId}`);
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
const roleQuery = query({
|
|
192
|
-
prompt: inputStream,
|
|
193
|
-
options: queryOptions
|
|
194
|
-
});
|
|
195
|
-
|
|
196
|
-
// resume 场景:保留已有 roleState 的 baseline,避免双重计算
|
|
197
|
-
// 只有 fresh query(无 savedSessionId)才用 0 初始化
|
|
198
|
-
const existingState = session.roleStates.get(roleName);
|
|
199
|
-
const isResume = !!savedSessionId;
|
|
200
|
-
|
|
201
|
-
const roleState = {
|
|
202
|
-
query: roleQuery,
|
|
203
|
-
inputStream,
|
|
204
|
-
abortController,
|
|
205
|
-
accumulatedText: '',
|
|
206
|
-
turnActive: false,
|
|
207
|
-
claudeSessionId: savedSessionId,
|
|
208
|
-
lastCostUsd: (isResume && existingState?.lastCostUsd) || 0,
|
|
209
|
-
lastInputTokens: (isResume && existingState?.lastInputTokens) || 0,
|
|
210
|
-
lastOutputTokens: (isResume && existingState?.lastOutputTokens) || 0,
|
|
211
|
-
lastSeenUsage: null,
|
|
212
|
-
consecutiveErrors: 0,
|
|
213
|
-
lastDispatchContent: null,
|
|
214
|
-
lastDispatchFrom: null,
|
|
215
|
-
lastDispatchTaskId: null,
|
|
216
|
-
lastDispatchTaskTitle: null
|
|
217
|
-
};
|
|
218
|
-
|
|
219
|
-
session.roleStates.set(roleName, roleState);
|
|
220
|
-
|
|
221
|
-
// 异步处理角色输出
|
|
222
|
-
processRoleOutput(session, roleName, roleQuery, roleState);
|
|
223
|
-
|
|
224
|
-
return roleState;
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
/**
|
|
228
|
-
* 构建角色的 system prompt
|
|
229
|
-
*/
|
|
230
|
-
export function buildRoleSystemPrompt(role, session) {
|
|
231
|
-
const allRoles = Array.from(session.roles.values());
|
|
232
|
-
|
|
233
|
-
let routeTargets;
|
|
234
|
-
if (role.groupIndex > 0) {
|
|
235
|
-
routeTargets = allRoles.filter(r =>
|
|
236
|
-
r.name !== role.name && (r.groupIndex === role.groupIndex || r.groupIndex === 0)
|
|
237
|
-
);
|
|
238
|
-
} else {
|
|
239
|
-
routeTargets = allRoles.filter(r => r.name !== role.name);
|
|
240
|
-
}
|
|
241
|
-
|
|
242
|
-
const m = getMessages(session.language || 'zh-CN');
|
|
243
|
-
|
|
244
|
-
let prompt = `${m.teamCollab}
|
|
245
|
-
${m.teamCollabIntro()}
|
|
246
|
-
|
|
247
|
-
${m.teamMembers}
|
|
248
|
-
${allRoles.map(r => `- ${roleLabel(r)}: ${r.description}${r.isDecisionMaker ? ` (${m.decisionMakerTag})` : ''}`).join('\n')}`;
|
|
249
|
-
|
|
250
|
-
const hasMultiInstance = allRoles.some(r => r.groupIndex > 0);
|
|
251
|
-
|
|
252
|
-
if (routeTargets.length > 0) {
|
|
253
|
-
prompt += `\n\n${m.routingRules}
|
|
254
|
-
${m.routingIntro}
|
|
255
|
-
|
|
256
|
-
\`\`\`
|
|
257
|
-
---ROUTE---
|
|
258
|
-
to: <roleName>
|
|
259
|
-
summary: <必须填写具体内容:你完成了什么、需要对方做什么。禁止留空!>
|
|
260
|
-
---END_ROUTE---
|
|
261
|
-
\`\`\`
|
|
262
|
-
|
|
263
|
-
${m.routeTargets}
|
|
264
|
-
${routeTargets.map(r => `- ${r.name}: ${roleLabel(r)} — ${r.description}`).join('\n')}
|
|
265
|
-
- human: ${m.humanTarget}
|
|
266
|
-
|
|
267
|
-
${m.routeNotes(session.decisionMaker)}`;
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
// 决策者额外 prompt
|
|
271
|
-
if (role.isDecisionMaker) {
|
|
272
|
-
const isDevTeam = session.teamType === 'dev';
|
|
273
|
-
|
|
274
|
-
prompt += `\n\n${m.toolUsage}
|
|
275
|
-
${m.toolUsageContent(isDevTeam)}`;
|
|
276
|
-
|
|
277
|
-
prompt += `\n\n${m.dmRole}
|
|
278
|
-
${m.dmRoleContent}`;
|
|
279
|
-
|
|
280
|
-
if (isDevTeam) {
|
|
281
|
-
prompt += m.dmDevExtra;
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
if (!isDevTeam) {
|
|
285
|
-
prompt += `\n\n${m.collabMode}
|
|
286
|
-
${m.collabModeContent}`;
|
|
287
|
-
}
|
|
288
|
-
|
|
289
|
-
if (isDevTeam && hasMultiInstance) {
|
|
290
|
-
const maxGroup = Math.max(...allRoles.map(r => r.groupIndex));
|
|
291
|
-
const groupLines = [];
|
|
292
|
-
for (let g = 1; g <= maxGroup; g++) {
|
|
293
|
-
const members = allRoles.filter(r => r.groupIndex === g);
|
|
294
|
-
const memberStrs = members.map(r => {
|
|
295
|
-
const state = session.roleStates.get(r.name);
|
|
296
|
-
const busy = state?.turnActive;
|
|
297
|
-
const task = state?.currentTask;
|
|
298
|
-
if (busy && task) return `${r.name}(${m.groupBusy(task.taskId + ' ' + task.taskTitle)})`;
|
|
299
|
-
if (busy) return `${r.name}(${m.groupBusyShort})`;
|
|
300
|
-
return `${r.name}(${m.groupIdle})`;
|
|
301
|
-
});
|
|
302
|
-
groupLines.push(`${m.groupLabel(g)}: ${memberStrs.join(' ')}`);
|
|
303
|
-
}
|
|
304
|
-
|
|
305
|
-
prompt += `\n\n${m.execGroupStatus}
|
|
306
|
-
${groupLines.join(' / ')}
|
|
307
|
-
|
|
308
|
-
${m.parallelRules}
|
|
309
|
-
${m.parallelRulesContent(maxGroup)}
|
|
310
|
-
|
|
311
|
-
\`\`\`
|
|
312
|
-
---ROUTE---
|
|
313
|
-
to: dev-1
|
|
314
|
-
task: task-1
|
|
315
|
-
taskTitle: ${m.implLoginPage}
|
|
316
|
-
summary: ${m.implLoginSummary}
|
|
317
|
-
---END_ROUTE---
|
|
318
|
-
|
|
319
|
-
---ROUTE---
|
|
320
|
-
to: dev-2
|
|
321
|
-
task: task-2
|
|
322
|
-
taskTitle: ${m.implRegisterPage}
|
|
323
|
-
summary: ${m.implRegisterSummary}
|
|
324
|
-
---END_ROUTE---
|
|
325
|
-
\`\`\`
|
|
326
|
-
|
|
327
|
-
${m.parallelExample}`;
|
|
328
|
-
}
|
|
329
|
-
|
|
330
|
-
prompt += `\n
|
|
331
|
-
${m.workflowEnd}
|
|
332
|
-
${m.workflowEndContent(isDevTeam)}
|
|
333
|
-
|
|
334
|
-
${m.taskList}
|
|
335
|
-
${m.taskListContent}
|
|
336
|
-
|
|
337
|
-
\`\`\`
|
|
338
|
-
${m.taskExample}
|
|
339
|
-
\`\`\`
|
|
340
|
-
|
|
341
|
-
${m.taskListNotes}`;
|
|
342
|
-
}
|
|
343
|
-
|
|
344
|
-
// 非 dev 团队的非决策者也需要知道协作模式
|
|
345
|
-
if (!role.isDecisionMaker && session.teamType !== 'dev') {
|
|
346
|
-
prompt += `\n\n${m.collabMode}
|
|
347
|
-
${m.collabModeContent}`;
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
// 非 DM 角色的团队特定协作建议
|
|
351
|
-
if (!role.isDecisionMaker && m.teamCollabFlow) {
|
|
352
|
-
// roleType 在多个团队中出现时用 roleType_teamType 消歧
|
|
353
|
-
const flowFn = m.teamCollabFlow[role.roleType + '_' + session.teamType]
|
|
354
|
-
|| m.teamCollabFlow[role.roleType];
|
|
355
|
-
if (flowFn) {
|
|
356
|
-
const flowText = flowFn(session.decisionMaker);
|
|
357
|
-
if (flowText) {
|
|
358
|
-
prompt += `\n\n${m.teamCollabFlowTitle}
|
|
359
|
-
${flowText}`;
|
|
360
|
-
}
|
|
361
|
-
}
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
// Feature 进度文件说明
|
|
365
|
-
prompt += `\n\n${m.featureRecordTitle}
|
|
366
|
-
${m.featureRecordContent}
|
|
367
|
-
|
|
368
|
-
${m.contextRestartTitle}
|
|
369
|
-
${m.contextRestartContent}`;
|
|
370
|
-
|
|
371
|
-
// 执行者角色的组绑定 prompt
|
|
372
|
-
if (role.groupIndex > 0 && role.roleType === 'developer') {
|
|
373
|
-
const gi = role.groupIndex;
|
|
374
|
-
const rev = allRoles.find(r => r.roleType === 'reviewer' && r.groupIndex === gi);
|
|
375
|
-
const prev = allRoles.find(r => r.roleType === 'product-reviewer' && r.groupIndex === gi);
|
|
376
|
-
if (rev && prev) {
|
|
377
|
-
prompt += `\n\n${m.devGroupBinding}
|
|
378
|
-
${m.devGroupBindingContent(gi, roleLabel(rev), rev.name, roleLabel(prev), prev.name)}
|
|
379
|
-
|
|
380
|
-
\`\`\`
|
|
381
|
-
---ROUTE---
|
|
382
|
-
to: ${rev.name}
|
|
383
|
-
summary: ${m.reviewCode}
|
|
384
|
-
---END_ROUTE---
|
|
385
|
-
|
|
386
|
-
---ROUTE---
|
|
387
|
-
to: ${prev.name}
|
|
388
|
-
summary: ${m.productReviewFeature}
|
|
389
|
-
---END_ROUTE---
|
|
390
|
-
\`\`\`
|
|
391
|
-
|
|
392
|
-
${m.devGroupBindingNote}`;
|
|
393
|
-
}
|
|
394
|
-
}
|
|
395
|
-
|
|
396
|
-
// Language instruction
|
|
397
|
-
if (session.language === 'en') {
|
|
398
|
-
prompt += `\n\n# Language
|
|
399
|
-
Always respond in English. Use English for all explanations, comments, and communications with the user. Technical terms and code identifiers should remain in their original form.`;
|
|
400
|
-
} else {
|
|
401
|
-
prompt += `\n\n# Language
|
|
402
|
-
Always respond in 中文. Use 中文 for all explanations, comments, and communications with the user. Technical terms and code identifiers should remain in their original form.`;
|
|
403
|
-
}
|
|
404
|
-
|
|
405
|
-
return prompt;
|
|
406
|
-
}
|
package/crew/role-states.js
DELETED
|
@@ -1,180 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Crew — Role State Store (task-330d, Fowler Final Spec §D)
|
|
3
|
-
*
|
|
4
|
-
* Replaces the legacy "standby task" pattern (where PM idle was modelled
|
|
5
|
-
* as a synthetic task in `.crew/context/features/standby.md`). Role idle/
|
|
6
|
-
* busy/pending state is now first-class and persisted to a single file:
|
|
7
|
-
*
|
|
8
|
-
* <sharedDir>/context/role-states.json
|
|
9
|
-
*
|
|
10
|
-
* File shape:
|
|
11
|
-
* {
|
|
12
|
-
* "version": 1,
|
|
13
|
-
* "states": {
|
|
14
|
-
* "<roleName>": { role, status, since, reason }
|
|
15
|
-
* }
|
|
16
|
-
* }
|
|
17
|
-
*
|
|
18
|
-
* Status values:
|
|
19
|
-
* - 'standby' : role has no active task and is waiting for routing
|
|
20
|
-
* - 'busy' : role is currently executing a turn
|
|
21
|
-
* - 'pending' : role finished a turn but did not emit a ROUTE
|
|
22
|
-
*
|
|
23
|
-
* Public API (consumed by the `role_standby` tool added in task-330a):
|
|
24
|
-
* - getRoleState(sharedDir, role) → state | null
|
|
25
|
-
* - setRoleState(sharedDir, role, patch) → state
|
|
26
|
-
* - listRoleStates(sharedDir) → Record<role, state>
|
|
27
|
-
*
|
|
28
|
-
* Atomicity: writes go to `role-states.json.tmp` then rename, matching the
|
|
29
|
-
* convention in persistence.js. A per-process write lock (Promise chain)
|
|
30
|
-
* serialises concurrent setRoleState calls so the file never tears.
|
|
31
|
-
*
|
|
32
|
-
* Backward compatibility: a session that was created before role-states.json
|
|
33
|
-
* existed simply has no file; getRoleState returns null and setRoleState
|
|
34
|
-
* creates the file lazily. No migration step is required.
|
|
35
|
-
*/
|
|
36
|
-
|
|
37
|
-
import { promises as fs } from 'fs';
|
|
38
|
-
import { join } from 'path';
|
|
39
|
-
|
|
40
|
-
const FILE_NAME = 'role-states.json';
|
|
41
|
-
const VERSION = 1;
|
|
42
|
-
const VALID_STATUSES = new Set(['standby', 'busy', 'pending']);
|
|
43
|
-
|
|
44
|
-
// Per-sharedDir write lock. Keyed by absolute path so multiple sessions in
|
|
45
|
-
// one process don't serialise against each other.
|
|
46
|
-
const _writeLocks = new Map();
|
|
47
|
-
|
|
48
|
-
function _lockKey(sharedDir) {
|
|
49
|
-
return sharedDir;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
function _filePath(sharedDir) {
|
|
53
|
-
return join(sharedDir, 'context', FILE_NAME);
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
async function _readAll(sharedDir) {
|
|
57
|
-
try {
|
|
58
|
-
const raw = await fs.readFile(_filePath(sharedDir), 'utf-8');
|
|
59
|
-
const parsed = JSON.parse(raw);
|
|
60
|
-
if (!parsed || typeof parsed !== 'object' || !parsed.states || typeof parsed.states !== 'object') {
|
|
61
|
-
return { version: VERSION, states: {} };
|
|
62
|
-
}
|
|
63
|
-
return { version: parsed.version || VERSION, states: parsed.states };
|
|
64
|
-
} catch (err) {
|
|
65
|
-
if (err && err.code === 'ENOENT') return { version: VERSION, states: {} };
|
|
66
|
-
// Corrupt JSON → start fresh rather than crash. This matches the
|
|
67
|
-
// "legacy session replay compatibility" red line: a broken file must
|
|
68
|
-
// not block resume.
|
|
69
|
-
return { version: VERSION, states: {} };
|
|
70
|
-
}
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
async function _writeAtomic(sharedDir, payload) {
|
|
74
|
-
const dir = join(sharedDir, 'context');
|
|
75
|
-
await fs.mkdir(dir, { recursive: true });
|
|
76
|
-
const target = _filePath(sharedDir);
|
|
77
|
-
const tmp = target + '.tmp';
|
|
78
|
-
await fs.writeFile(tmp, JSON.stringify(payload, null, 2));
|
|
79
|
-
await fs.rename(tmp, target);
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
/**
|
|
83
|
-
* Read the current state for a single role.
|
|
84
|
-
* @param {string} sharedDir — session shared directory (`.crew` root)
|
|
85
|
-
* @param {string} role — role name
|
|
86
|
-
* @returns {Promise<null | {role:string, status:string, since:number, reason?:string}>}
|
|
87
|
-
*/
|
|
88
|
-
export async function getRoleState(sharedDir, role) {
|
|
89
|
-
if (!sharedDir || !role) return null;
|
|
90
|
-
const all = await _readAll(sharedDir);
|
|
91
|
-
return all.states[role] || null;
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
/**
|
|
95
|
-
* Read every role's state at once. Returns a plain object keyed by role
|
|
96
|
-
* name (empty when the file is missing).
|
|
97
|
-
*/
|
|
98
|
-
export async function listRoleStates(sharedDir) {
|
|
99
|
-
if (!sharedDir) return {};
|
|
100
|
-
const all = await _readAll(sharedDir);
|
|
101
|
-
return { ...all.states };
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
/**
|
|
105
|
-
* Patch a single role's state. Unspecified fields are preserved from the
|
|
106
|
-
* existing record. `since` defaults to Date.now() when status changes (or
|
|
107
|
-
* when no prior record exists). Throws on invalid status — callers are
|
|
108
|
-
* expected to pass one of standby|busy|pending.
|
|
109
|
-
*
|
|
110
|
-
* @param {string} sharedDir
|
|
111
|
-
* @param {string} role
|
|
112
|
-
* @param {Partial<{status:string, reason:string, since:number}>} patch
|
|
113
|
-
* @returns {Promise<{role:string, status:string, since:number, reason?:string}>}
|
|
114
|
-
*/
|
|
115
|
-
export async function setRoleState(sharedDir, role, patch = {}) {
|
|
116
|
-
if (!sharedDir) throw new Error('setRoleState: sharedDir required');
|
|
117
|
-
if (!role) throw new Error('setRoleState: role required');
|
|
118
|
-
if (patch.status !== undefined && !VALID_STATUSES.has(patch.status)) {
|
|
119
|
-
throw new Error(`setRoleState: invalid status '${patch.status}'`);
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
const key = _lockKey(sharedDir);
|
|
123
|
-
const prev = _writeLocks.get(key) || Promise.resolve();
|
|
124
|
-
|
|
125
|
-
const next = prev.then(async () => {
|
|
126
|
-
const all = await _readAll(sharedDir);
|
|
127
|
-
const existing = all.states[role] || null;
|
|
128
|
-
const statusChanged = patch.status !== undefined && (!existing || existing.status !== patch.status);
|
|
129
|
-
const merged = {
|
|
130
|
-
role,
|
|
131
|
-
status: patch.status !== undefined ? patch.status : (existing ? existing.status : 'standby'),
|
|
132
|
-
since: patch.since !== undefined
|
|
133
|
-
? patch.since
|
|
134
|
-
: (statusChanged || !existing ? Date.now() : existing.since),
|
|
135
|
-
};
|
|
136
|
-
if (patch.reason !== undefined) {
|
|
137
|
-
merged.reason = patch.reason;
|
|
138
|
-
} else if (existing && existing.reason !== undefined && !statusChanged) {
|
|
139
|
-
merged.reason = existing.reason;
|
|
140
|
-
}
|
|
141
|
-
all.states[role] = merged;
|
|
142
|
-
all.version = VERSION;
|
|
143
|
-
await _writeAtomic(sharedDir, all);
|
|
144
|
-
return merged;
|
|
145
|
-
}, async () => {
|
|
146
|
-
// If a prior write rejected we still want to attempt this one rather
|
|
147
|
-
// than poisoning the chain forever.
|
|
148
|
-
const all = await _readAll(sharedDir);
|
|
149
|
-
const merged = {
|
|
150
|
-
role,
|
|
151
|
-
status: patch.status || 'standby',
|
|
152
|
-
since: patch.since !== undefined ? patch.since : Date.now(),
|
|
153
|
-
...(patch.reason !== undefined ? { reason: patch.reason } : {}),
|
|
154
|
-
};
|
|
155
|
-
all.states[role] = merged;
|
|
156
|
-
await _writeAtomic(sharedDir, all);
|
|
157
|
-
return merged;
|
|
158
|
-
});
|
|
159
|
-
|
|
160
|
-
// Hold onto the chain so the next caller waits on this write.
|
|
161
|
-
_writeLocks.set(key, next.catch(() => {}));
|
|
162
|
-
return next;
|
|
163
|
-
}
|
|
164
|
-
|
|
165
|
-
/**
|
|
166
|
-
* Test/diagnostic helper — clear in-process write lock for a sharedDir.
|
|
167
|
-
* Production callers should never need this; tests use it between runs to
|
|
168
|
-
* avoid bleeding lock state across describe blocks.
|
|
169
|
-
*/
|
|
170
|
-
export function __resetWriteLockForTests(sharedDir) {
|
|
171
|
-
if (sharedDir) _writeLocks.delete(sharedDir);
|
|
172
|
-
else _writeLocks.clear();
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
/**
|
|
176
|
-
* Constants exported for tests + downstream consumers (the `role_standby`
|
|
177
|
-
* tool in task-330a).
|
|
178
|
-
*/
|
|
179
|
-
export const ROLE_STATE_FILE_NAME = FILE_NAME;
|
|
180
|
-
export const ROLE_STATE_STATUSES = ['standby', 'busy', 'pending'];
|
package/crew/routing-fallback.js
DELETED
|
@@ -1,64 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* task-330b — Single fallback-target resolver (Final Spec §B item 3).
|
|
3
|
-
*
|
|
4
|
-
* Replaces ad-hoc `session.decisionMaker` lookups scattered across
|
|
5
|
-
* routing.js / role-output.js / human-interaction.js when a message has
|
|
6
|
-
* nowhere obvious to go. Centralising this in ONE function lets us:
|
|
7
|
-
*
|
|
8
|
-
* - test the fallback policy in isolation
|
|
9
|
-
* - add per-reason policy without touching every call site
|
|
10
|
-
* - keep the §B `recordRoutingEvent` calls right next to the decision
|
|
11
|
-
*
|
|
12
|
-
* Policy (mirrors what the codebase already does — this is a refactor,
|
|
13
|
-
* not a behaviour change for the existing 4 reasons):
|
|
14
|
-
*
|
|
15
|
-
* missing-route → PM (decisionMaker) IF caller is non-PM AND has
|
|
16
|
-
* active task or routing intent. Else: pending (null).
|
|
17
|
-
* PM-no-auto-forward rule (§B item 2): if caller IS
|
|
18
|
-
* PM → ALWAYS pending (null), even with active task.
|
|
19
|
-
* parse-fail → PM (decisionMaker)
|
|
20
|
-
* self-route → null (rejected; §A handles, §B only logs)
|
|
21
|
-
* state-stopped → null (let session.status block the dispatch)
|
|
22
|
-
* fallback-forward → PM (decisionMaker) — explicit auto-forward path
|
|
23
|
-
*
|
|
24
|
-
* Returns the target role NAME, or null when "do nothing / pending".
|
|
25
|
-
*
|
|
26
|
-
* @param {object} session — crew session (.decisionMaker, .roles)
|
|
27
|
-
* @param {string} fromRole
|
|
28
|
-
* @param {string} reason — one of ROUTING_REASONS
|
|
29
|
-
* @param {object} [opts]
|
|
30
|
-
* @param {boolean} [opts.hasActiveTask]
|
|
31
|
-
* @param {boolean} [opts.hasRouteIntent]
|
|
32
|
-
* @returns {string|null}
|
|
33
|
-
*/
|
|
34
|
-
export function resolveFallbackTarget(session, fromRole, reason, opts = {}) {
|
|
35
|
-
if (!session) return null;
|
|
36
|
-
const dm = session.decisionMaker || null;
|
|
37
|
-
|
|
38
|
-
switch (reason) {
|
|
39
|
-
case 'missing-route': {
|
|
40
|
-
// PM-no-auto-forward rule (§B item 2): PM never auto-forwards to
|
|
41
|
-
// itself. Returning null signals "park as pending, wait for human".
|
|
42
|
-
if (fromRole === dm) return null;
|
|
43
|
-
// Non-PM: only auto-forward when there is something to forward
|
|
44
|
-
// (active task OR detected routing intent in the prose).
|
|
45
|
-
if (opts.hasActiveTask || opts.hasRouteIntent) return dm;
|
|
46
|
-
return null;
|
|
47
|
-
}
|
|
48
|
-
case 'parse-fail':
|
|
49
|
-
// Parser found a ROUTE block but couldn't read it — PM should see
|
|
50
|
-
// the malformed output to decide next step.
|
|
51
|
-
return dm;
|
|
52
|
-
case 'self-route':
|
|
53
|
-
// §A rejects self-routing; §B records, no fallback dispatch.
|
|
54
|
-
return null;
|
|
55
|
-
case 'state-stopped':
|
|
56
|
-
// Session is stopped/paused; routing must not auto-resume here.
|
|
57
|
-
return null;
|
|
58
|
-
case 'fallback-forward':
|
|
59
|
-
// Explicit safety net (the rename of the existing auto-forward).
|
|
60
|
-
return dm;
|
|
61
|
-
default:
|
|
62
|
-
return null;
|
|
63
|
-
}
|
|
64
|
-
}
|