@yeaft/webchat-agent 1.0.78 → 1.0.79
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 +6 -2
- package/connection/message-router.js +64 -1
- package/conversation.js +32 -4
- package/crew/builtin-actions.js +154 -0
- package/crew/context-loader.js +171 -0
- package/crew/control.js +444 -0
- package/crew/human-interaction.js +195 -0
- package/crew/persistence.js +295 -0
- package/crew/role-management.js +182 -0
- package/crew/role-output.js +461 -0
- package/crew/role-query.js +406 -0
- package/crew/role-states.js +180 -0
- package/crew/routing-fallback.js +64 -0
- package/crew/routing-metrics.js +215 -0
- package/crew/routing.js +951 -0
- package/crew/session.js +648 -0
- package/crew/shared-dir.js +266 -0
- package/crew/task-files.js +554 -0
- package/crew/ui-messages.js +274 -0
- package/crew/worktree.js +130 -0
- package/crew-i18n.js +579 -0
- package/crew.js +48 -0
- package/history.js +2 -2
- package/index.js +6 -0
- 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 +112 -458
- package/yeaft/conversation/search.js +15 -51
- package/yeaft/session.js +18 -27
- package/yeaft/web-bridge.js +15 -27
package/connection/buffer.js
CHANGED
|
@@ -9,7 +9,11 @@ export const BUFFERABLE_TYPES = new Set([
|
|
|
9
9
|
'turn_completed', 'conversation_closed',
|
|
10
10
|
'session_id_update', 'compact_status', 'slash_commands_update',
|
|
11
11
|
'background_task_started', 'background_task_output',
|
|
12
|
-
'subagent_started', 'subagent_message', 'subagent_completed'
|
|
12
|
+
'subagent_started', 'subagent_message', 'subagent_completed',
|
|
13
|
+
'crew_output', 'crew_status', 'crew_turn_completed',
|
|
14
|
+
'crew_session_created', 'crew_session_restored', 'crew_human_needed',
|
|
15
|
+
'crew_role_added', 'crew_role_removed',
|
|
16
|
+
'crew_role_compact', 'crew_context_usage'
|
|
13
17
|
]);
|
|
14
18
|
|
|
15
19
|
function bufferMessage(msg, reason) {
|
|
@@ -23,7 +27,7 @@ function bufferMessage(msg, reason) {
|
|
|
23
27
|
return 'buffered';
|
|
24
28
|
}
|
|
25
29
|
// Buffer full: drop oldest non-status messages to make room
|
|
26
|
-
const dropIdx = ctx.messageBuffer.findIndex(m => m.type !== 'turn_completed');
|
|
30
|
+
const dropIdx = ctx.messageBuffer.findIndex(m => m.type !== 'crew_status' && m.type !== 'turn_completed');
|
|
27
31
|
if (dropIdx >= 0) {
|
|
28
32
|
ctx.messageBuffer.splice(dropIdx, 1);
|
|
29
33
|
ctx.messageBuffer.push(msg);
|
|
@@ -3,7 +3,11 @@
|
|
|
3
3
|
*
|
|
4
4
|
* task-330c lint guard:
|
|
5
5
|
* ⚠️ DO NOT introduce greedy `text.replace(/---ROUTE---[\s\S]*$/g, '')`
|
|
6
|
-
* style strips on routed message payloads.
|
|
6
|
+
* style strips on routed message payloads. Crew ROUTE stripping is
|
|
7
|
+
* owned EXCLUSIVELY by `agent/crew/routing.js` `parseRoutes()` which
|
|
8
|
+
* returns `{routes, displayBody}` with exact ranges removed. A second
|
|
9
|
+
* strip here would re-process already-cleaned text and risks both
|
|
10
|
+
* double-strip artefacts and the trailing-prose bug fixed by task-328.
|
|
7
11
|
*/
|
|
8
12
|
import ctx from '../context.js';
|
|
9
13
|
import { decodeKey } from '../encryption.js';
|
|
@@ -22,6 +26,12 @@ import {
|
|
|
22
26
|
sendConversationList, handleBtwQuestion, preloadSlashCommands,
|
|
23
27
|
handlePingSession
|
|
24
28
|
} from '../conversation.js';
|
|
29
|
+
import {
|
|
30
|
+
createCrewSession, handleCrewHumanInput, handleCrewControl,
|
|
31
|
+
addRoleToSession, removeRoleFromSession,
|
|
32
|
+
handleListCrewSessions, handleCheckCrewExists, handleDeleteCrewDir, resumeCrewSession, removeFromCrewIndex,
|
|
33
|
+
handleLoadCrewHistory, handleCheckCrewContext
|
|
34
|
+
} from '../crew.js';
|
|
25
35
|
import { sendToServer, flushMessageBuffer } from './buffer.js';
|
|
26
36
|
import { handleRestartAgent, handleUpgradeAgent } from './upgrade.js';
|
|
27
37
|
import { loadMcpServers, updateMcpConfig } from '../mcp.js';
|
|
@@ -108,6 +118,10 @@ export async function handleMessage(msg) {
|
|
|
108
118
|
preloadSlashCommands().catch(() => {});
|
|
109
119
|
break;
|
|
110
120
|
|
|
121
|
+
case 'check_crew_context':
|
|
122
|
+
handleCheckCrewContext(msg);
|
|
123
|
+
break;
|
|
124
|
+
|
|
111
125
|
case 'resume_conversation':
|
|
112
126
|
await resumeConversation(msg);
|
|
113
127
|
break;
|
|
@@ -248,6 +262,55 @@ export async function handleMessage(msg) {
|
|
|
248
262
|
handleAskUserAnswer(msg);
|
|
249
263
|
break;
|
|
250
264
|
|
|
265
|
+
// Crew (multi-agent) messages
|
|
266
|
+
case 'create_crew_session':
|
|
267
|
+
await createCrewSession(msg);
|
|
268
|
+
break;
|
|
269
|
+
|
|
270
|
+
case 'crew_human_input':
|
|
271
|
+
await handleCrewHumanInput(msg);
|
|
272
|
+
break;
|
|
273
|
+
|
|
274
|
+
case 'crew_control':
|
|
275
|
+
await handleCrewControl(msg);
|
|
276
|
+
break;
|
|
277
|
+
|
|
278
|
+
case 'crew_add_role':
|
|
279
|
+
await addRoleToSession(msg);
|
|
280
|
+
break;
|
|
281
|
+
|
|
282
|
+
case 'crew_remove_role':
|
|
283
|
+
await removeRoleFromSession(msg);
|
|
284
|
+
break;
|
|
285
|
+
|
|
286
|
+
case 'list_crew_sessions':
|
|
287
|
+
await handleListCrewSessions(msg);
|
|
288
|
+
break;
|
|
289
|
+
|
|
290
|
+
case 'check_crew_exists':
|
|
291
|
+
await handleCheckCrewExists(msg);
|
|
292
|
+
break;
|
|
293
|
+
|
|
294
|
+
case 'delete_crew_dir':
|
|
295
|
+
await handleDeleteCrewDir(msg);
|
|
296
|
+
break;
|
|
297
|
+
|
|
298
|
+
case 'resume_crew_session':
|
|
299
|
+
await resumeCrewSession(msg);
|
|
300
|
+
break;
|
|
301
|
+
|
|
302
|
+
case 'delete_crew_session':
|
|
303
|
+
await removeFromCrewIndex(msg.sessionId);
|
|
304
|
+
(await import('../conversation.js')).sendConversationList();
|
|
305
|
+
break;
|
|
306
|
+
|
|
307
|
+
case 'update_crew_session':
|
|
308
|
+
await (await import('../crew.js')).handleUpdateCrewSession(msg);
|
|
309
|
+
break;
|
|
310
|
+
|
|
311
|
+
case 'crew_load_history':
|
|
312
|
+
await handleLoadCrewHistory(msg);
|
|
313
|
+
break;
|
|
251
314
|
|
|
252
315
|
// Port proxy
|
|
253
316
|
case 'proxy_request':
|
package/conversation.js
CHANGED
|
@@ -5,6 +5,7 @@ import ctx from './context.js';
|
|
|
5
5
|
import { query } from './sdk/index.js';
|
|
6
6
|
import { loadSessionHistory } from './history.js';
|
|
7
7
|
import { startClaudeQuery } from './claude.js';
|
|
8
|
+
import { crewSessions } from './crew.js';
|
|
8
9
|
import { getProvider, DEFAULT_PROVIDER, isValidProvider } from './providers/index.js';
|
|
9
10
|
|
|
10
11
|
// 不支持的斜杠命令(真正需要交互式 CLI 的命令)
|
|
@@ -190,7 +191,7 @@ function prestartClaude(conversationId, workDir, resumeSessionId) {
|
|
|
190
191
|
*
|
|
191
192
|
* @param {string} [workDir] - Project directory (default: agent workDir)
|
|
192
193
|
* @param {string} [targetId] - conversationId to key the update to
|
|
193
|
-
* ('__preload__' for agent-level)
|
|
194
|
+
* ('__preload__' for agent-level, or crewSessionId)
|
|
194
195
|
*/
|
|
195
196
|
export async function preloadSlashCommands(workDir, targetId = '__preload__') {
|
|
196
197
|
const effectiveWorkDir = workDir || ctx.CONFIG.workDir;
|
|
@@ -282,7 +283,7 @@ export function parseSlashCommand(message) {
|
|
|
282
283
|
return { type: null, message };
|
|
283
284
|
}
|
|
284
285
|
|
|
285
|
-
// 发送 conversation
|
|
286
|
+
// 发送 conversation 列表(仅含活跃 crew sessions;历史 Crew 索引按需通过 list_crew_sessions 加载)
|
|
286
287
|
export async function sendConversationList() {
|
|
287
288
|
const list = [];
|
|
288
289
|
for (const [id, state] of ctx.conversations) {
|
|
@@ -304,6 +305,19 @@ export async function sendConversationList() {
|
|
|
304
305
|
};
|
|
305
306
|
list.push(entry);
|
|
306
307
|
}
|
|
308
|
+
// 追加活跃 crew sessions。历史 Crew 索引可能触发磁盘读取,保持按需加载,
|
|
309
|
+
// 由显式 list_crew_sessions 请求处理,避免普通 Chat/Yeaft 列表提前加载 Crew。
|
|
310
|
+
for (const [id, session] of crewSessions) {
|
|
311
|
+
list.push({
|
|
312
|
+
id,
|
|
313
|
+
workDir: session.projectDir,
|
|
314
|
+
createdAt: session.createdAt,
|
|
315
|
+
processing: session.status === 'running',
|
|
316
|
+
userId: session.userId,
|
|
317
|
+
username: session.username,
|
|
318
|
+
type: 'crew',
|
|
319
|
+
});
|
|
320
|
+
}
|
|
307
321
|
ctx.sendToServer({
|
|
308
322
|
type: 'conversation_list',
|
|
309
323
|
conversations: list
|
|
@@ -1032,16 +1046,19 @@ export function handleAskUserAnswer(msg) {
|
|
|
1032
1046
|
}
|
|
1033
1047
|
|
|
1034
1048
|
/**
|
|
1035
|
-
* Handle /btw side question.
|
|
1049
|
+
* Handle /btw side question — supports multi-turn and Crew mode.
|
|
1036
1050
|
*
|
|
1037
1051
|
* Multi-turn: First question forks the session (forkSession: true).
|
|
1038
1052
|
* Subsequent questions resume the forked session (no fork).
|
|
1039
1053
|
* The forked session ID is captured from system init and returned in btw_done.
|
|
1054
|
+
*
|
|
1055
|
+
* Crew mode: If conversationId is a crew session, use the decision maker's
|
|
1056
|
+
* claudeSessionId as the base for forking.
|
|
1040
1057
|
*/
|
|
1041
1058
|
export async function handleBtwQuestion(msg) {
|
|
1042
1059
|
const { conversationId, question, btwSessionId } = msg;
|
|
1043
1060
|
|
|
1044
|
-
// 1. Find the base
|
|
1061
|
+
// 1. Find the base session — Chat or Crew decision maker
|
|
1045
1062
|
let baseSessionId = null;
|
|
1046
1063
|
let workDir = null;
|
|
1047
1064
|
|
|
@@ -1049,6 +1066,17 @@ export async function handleBtwQuestion(msg) {
|
|
|
1049
1066
|
if (chatState?.claudeSessionId) {
|
|
1050
1067
|
baseSessionId = chatState.claudeSessionId;
|
|
1051
1068
|
workDir = chatState.workDir;
|
|
1069
|
+
} else {
|
|
1070
|
+
// Crew mode: find decision maker's session
|
|
1071
|
+
const crewSession = crewSessions.get(conversationId);
|
|
1072
|
+
if (crewSession) {
|
|
1073
|
+
const dmName = crewSession.decisionMaker;
|
|
1074
|
+
const dmState = dmName ? crewSession.roleStates.get(dmName) : null;
|
|
1075
|
+
if (dmState?.claudeSessionId) {
|
|
1076
|
+
baseSessionId = dmState.claudeSessionId;
|
|
1077
|
+
workDir = crewSession.projectDir;
|
|
1078
|
+
}
|
|
1079
|
+
}
|
|
1052
1080
|
}
|
|
1053
1081
|
|
|
1054
1082
|
if (!baseSessionId) {
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Crew — built-in role actions (task-330a §B)
|
|
3
|
+
*
|
|
4
|
+
* These two actions replace the "PM 给自己发闭环消息" anti-pattern that
|
|
5
|
+
* task-330a §A now rejects at the routing layer:
|
|
6
|
+
*
|
|
7
|
+
* • taskClose(session, { taskId, summary, fromRole })
|
|
8
|
+
* — directly mark the task complete on the kanban + broadcast a
|
|
9
|
+
* status card via sendCrewMessage. No ROUTE round-trip.
|
|
10
|
+
*
|
|
11
|
+
* • roleStandby(session, { role, reason, fromRole })
|
|
12
|
+
* — flip a role's runtime state to 'standby' and broadcast.
|
|
13
|
+
* Persistence to .crew/context/role-states.json is a 330d
|
|
14
|
+
* concern — we only define the in-memory contract here.
|
|
15
|
+
*
|
|
16
|
+
* Both functions are pure server-side helpers. They do NOT consume a
|
|
17
|
+
* routing turn (no session.round++, no dispatchToRole). Callers that
|
|
18
|
+
* choose to expose them as Claude tools (via canCallTool / mcp) must
|
|
19
|
+
* wire that separately — task-330a defines the contract; tool-binding
|
|
20
|
+
* lands in 330d alongside the role-state persistence layer.
|
|
21
|
+
*
|
|
22
|
+
* Red lines (per PM dispatch):
|
|
23
|
+
* - Do not mutate routing protocol shape (.routes / displayBody).
|
|
24
|
+
* - Do not break task-319/328 (parser tests stay green).
|
|
25
|
+
* - Old session replay must keep working — we only add new keys to
|
|
26
|
+
* session.roleStates[role], never remove.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { sendCrewMessage, sendStatusUpdate } from './ui-messages.js';
|
|
30
|
+
import { updateKanban, appendChangelog, updateFeatureIndex, isValidTaskId } from './task-files.js';
|
|
31
|
+
|
|
32
|
+
/** Valid standby reasons — extend with care; consumers may grep for these. */
|
|
33
|
+
export const STANDBY_REASONS = Object.freeze([
|
|
34
|
+
'task_closed', 'awaiting_input', 'manual', 'idle', 'paused',
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Mark a task as complete on the kanban + broadcast a status card.
|
|
39
|
+
*
|
|
40
|
+
* Mirrors the side-effects that role-output.js performs when it detects a
|
|
41
|
+
* completed TASKS block, so the two paths converge on the same kanban
|
|
42
|
+
* state regardless of which one fires.
|
|
43
|
+
*
|
|
44
|
+
* @param {object} session
|
|
45
|
+
* @param {{ taskId: string, summary?: string, fromRole?: string }} params
|
|
46
|
+
* @returns {Promise<{ ok: boolean, taskId: string, reason?: string }>}
|
|
47
|
+
*/
|
|
48
|
+
export async function taskClose(session, { taskId, summary, fromRole }) {
|
|
49
|
+
if (!session) {
|
|
50
|
+
return { ok: false, taskId: taskId || null, reason: 'no_session' };
|
|
51
|
+
}
|
|
52
|
+
if (!taskId || !isValidTaskId(taskId)) {
|
|
53
|
+
return { ok: false, taskId: taskId || null, reason: 'invalid_task_id' };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
// Mark in the in-memory completion set so future kanban rebuilds keep it.
|
|
57
|
+
if (!session._completedTaskIds) session._completedTaskIds = new Set();
|
|
58
|
+
const wasAlreadyCompleted = session._completedTaskIds.has(taskId);
|
|
59
|
+
session._completedTaskIds.add(taskId);
|
|
60
|
+
|
|
61
|
+
const feature = session.features?.get(taskId);
|
|
62
|
+
const taskTitle = feature?.taskTitle || taskId;
|
|
63
|
+
const cleanSummary = (summary && String(summary).trim()) || '已完成';
|
|
64
|
+
|
|
65
|
+
// Persist to the kanban file (best-effort; warns on failure).
|
|
66
|
+
try {
|
|
67
|
+
await updateKanban(session, { taskId, completed: true, summary: cleanSummary });
|
|
68
|
+
} catch (e) {
|
|
69
|
+
console.warn(`[Crew] taskClose: updateKanban failed for ${taskId}:`, e.message);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Append to features index + changelog only on first close (idempotent).
|
|
73
|
+
if (!wasAlreadyCompleted) {
|
|
74
|
+
updateFeatureIndex(session)
|
|
75
|
+
.catch(e => console.warn('[Crew] taskClose: updateFeatureIndex failed:', e.message));
|
|
76
|
+
appendChangelog(session, taskId, taskTitle)
|
|
77
|
+
.catch(e => console.warn(`[Crew] taskClose: appendChangelog failed for ${taskId}:`, e.message));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Broadcast status card so the UI reflects the close immediately.
|
|
81
|
+
try {
|
|
82
|
+
sendCrewMessage({
|
|
83
|
+
type: 'crew_task_closed',
|
|
84
|
+
sessionId: session.id,
|
|
85
|
+
taskId,
|
|
86
|
+
taskTitle,
|
|
87
|
+
summary: cleanSummary,
|
|
88
|
+
fromRole: fromRole || null,
|
|
89
|
+
timestamp: Date.now(),
|
|
90
|
+
});
|
|
91
|
+
sendStatusUpdate(session);
|
|
92
|
+
} catch (e) {
|
|
93
|
+
console.warn(`[Crew] taskClose: broadcast failed for ${taskId}:`, e.message);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
return { ok: true, taskId };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Flip a role into 'standby' (in-memory) and broadcast.
|
|
101
|
+
*
|
|
102
|
+
* 330a defines the contract; 330d wires the .crew/context/role-states.json
|
|
103
|
+
* persistence. We DO mutate `session.roleStates[role].standby` here so the
|
|
104
|
+
* UI / status pipeline can reflect the change immediately, and 330d can
|
|
105
|
+
* snapshot it on the next debounced save.
|
|
106
|
+
*
|
|
107
|
+
* @param {object} session
|
|
108
|
+
* @param {{ role: string, reason?: string, fromRole?: string }} params
|
|
109
|
+
* @returns {{ ok: boolean, role: string, reason?: string }}
|
|
110
|
+
*/
|
|
111
|
+
export function roleStandby(session, { role, reason, fromRole }) {
|
|
112
|
+
if (!session) {
|
|
113
|
+
return { ok: false, role: role || null, reason: 'no_session' };
|
|
114
|
+
}
|
|
115
|
+
if (!role || typeof role !== 'string') {
|
|
116
|
+
return { ok: false, role: role || null, reason: 'invalid_role' };
|
|
117
|
+
}
|
|
118
|
+
if (!session.roles || !session.roles.has(role)) {
|
|
119
|
+
return { ok: false, role, reason: 'unknown_role' };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const normalizedReason = reason && STANDBY_REASONS.includes(reason)
|
|
123
|
+
? reason
|
|
124
|
+
: 'manual';
|
|
125
|
+
|
|
126
|
+
// Ensure the roleState bucket exists (cold-start safe).
|
|
127
|
+
let roleState = session.roleStates?.get?.(role);
|
|
128
|
+
if (!roleState) {
|
|
129
|
+
roleState = {};
|
|
130
|
+
session.roleStates?.set?.(role, roleState);
|
|
131
|
+
}
|
|
132
|
+
// New keys only — never delete legacy fields (replay safety).
|
|
133
|
+
roleState.standby = {
|
|
134
|
+
reason: normalizedReason,
|
|
135
|
+
since: Date.now(),
|
|
136
|
+
setBy: fromRole || null,
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
try {
|
|
140
|
+
sendCrewMessage({
|
|
141
|
+
type: 'crew_role_standby',
|
|
142
|
+
sessionId: session.id,
|
|
143
|
+
role,
|
|
144
|
+
reason: normalizedReason,
|
|
145
|
+
fromRole: fromRole || null,
|
|
146
|
+
timestamp: Date.now(),
|
|
147
|
+
});
|
|
148
|
+
sendStatusUpdate(session);
|
|
149
|
+
} catch (e) {
|
|
150
|
+
console.warn(`[Crew] roleStandby: broadcast failed for ${role}:`, e.message);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return { ok: true, role, reason: normalizedReason };
|
|
154
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Crew Context Loader — detect and parse .crew/ directory structure.
|
|
3
|
+
*
|
|
4
|
+
* Reads .crew/CLAUDE.md, session.json, per-role CLAUDE.md files,
|
|
5
|
+
* kanban, and feature files. Used by the frontend to detect whether
|
|
6
|
+
* a project has a Crew setup and display role metadata.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { join } from 'path';
|
|
10
|
+
import { readFileSync, existsSync, readdirSync } from 'fs';
|
|
11
|
+
import ctx from '../context.js';
|
|
12
|
+
|
|
13
|
+
const MAX_CLAUDE_MD_LEN = 8192;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Read a file, returning null on any error.
|
|
17
|
+
* @param {string} filePath
|
|
18
|
+
* @returns {string|null}
|
|
19
|
+
*/
|
|
20
|
+
function readFileOrNull(filePath) {
|
|
21
|
+
try {
|
|
22
|
+
if (!existsSync(filePath)) return null;
|
|
23
|
+
return readFileSync(filePath, 'utf-8');
|
|
24
|
+
} catch {
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Deduplicate Crew roles by roleType.
|
|
31
|
+
* Crew may have dev-1, dev-2, dev-3 — collapse to a single "dev" entry.
|
|
32
|
+
* Attaches per-role CLAUDE.md content.
|
|
33
|
+
*/
|
|
34
|
+
function deduplicateRoles(sessionRoles, roleClaudes) {
|
|
35
|
+
const byType = new Map();
|
|
36
|
+
const merged = [];
|
|
37
|
+
|
|
38
|
+
for (const r of sessionRoles) {
|
|
39
|
+
const type = r.roleType || r.name;
|
|
40
|
+
const claudeMd = roleClaudes[r.name] || '';
|
|
41
|
+
|
|
42
|
+
if (byType.has(type)) continue;
|
|
43
|
+
byType.set(type, true);
|
|
44
|
+
|
|
45
|
+
const name = type;
|
|
46
|
+
let displayName = r.displayName || name;
|
|
47
|
+
displayName = displayName.replace(/-\d+$/, '');
|
|
48
|
+
|
|
49
|
+
merged.push({
|
|
50
|
+
name,
|
|
51
|
+
displayName,
|
|
52
|
+
icon: r.icon || '',
|
|
53
|
+
description: r.description || '',
|
|
54
|
+
claudeMd: claudeMd.substring(0, MAX_CLAUDE_MD_LEN),
|
|
55
|
+
roleType: type,
|
|
56
|
+
isDecisionMaker: !!r.isDecisionMaker,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
return merged;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Load .crew context from a project directory.
|
|
65
|
+
* Returns null if .crew/ doesn't exist.
|
|
66
|
+
*/
|
|
67
|
+
export function loadCrewContext(projectDir) {
|
|
68
|
+
const crewDir = join(projectDir, '.crew');
|
|
69
|
+
if (!existsSync(crewDir)) return null;
|
|
70
|
+
|
|
71
|
+
// 1. Shared CLAUDE.md
|
|
72
|
+
const sharedClaudeMd = readFileOrNull(join(crewDir, 'CLAUDE.md')) || '';
|
|
73
|
+
|
|
74
|
+
// 2. session.json → roles, teamType, language, features
|
|
75
|
+
let sessionRoles = [];
|
|
76
|
+
let teamType = 'dev';
|
|
77
|
+
let language = 'zh-CN';
|
|
78
|
+
let sessionFeatures = [];
|
|
79
|
+
const sessionPath = join(crewDir, 'session.json');
|
|
80
|
+
const sessionJson = readFileOrNull(sessionPath);
|
|
81
|
+
if (sessionJson) {
|
|
82
|
+
try {
|
|
83
|
+
const session = JSON.parse(sessionJson);
|
|
84
|
+
if (Array.isArray(session.roles)) sessionRoles = session.roles;
|
|
85
|
+
if (session.teamType) teamType = session.teamType;
|
|
86
|
+
if (session.language) language = session.language;
|
|
87
|
+
if (Array.isArray(session.features)) sessionFeatures = session.features;
|
|
88
|
+
} catch {
|
|
89
|
+
// Invalid JSON — ignore
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// 3. Per-role CLAUDE.md from .crew/roles/*/CLAUDE.md
|
|
94
|
+
const roleClaudes = {};
|
|
95
|
+
const rolesDir = join(crewDir, 'roles');
|
|
96
|
+
if (existsSync(rolesDir)) {
|
|
97
|
+
try {
|
|
98
|
+
const roleDirs = readdirSync(rolesDir, { withFileTypes: true })
|
|
99
|
+
.filter(d => d.isDirectory())
|
|
100
|
+
.map(d => d.name);
|
|
101
|
+
for (const dirName of roleDirs) {
|
|
102
|
+
const md = readFileOrNull(join(rolesDir, dirName, 'CLAUDE.md'));
|
|
103
|
+
if (md) roleClaudes[dirName] = md;
|
|
104
|
+
}
|
|
105
|
+
} catch {
|
|
106
|
+
// Permission error or similar — ignore
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// 4. Merge roles: deduplicate by roleType, attach claudeMd
|
|
111
|
+
const roles = deduplicateRoles(sessionRoles, roleClaudes);
|
|
112
|
+
|
|
113
|
+
// 5. Kanban
|
|
114
|
+
const kanban = readFileOrNull(join(crewDir, 'context', 'kanban.md')) || '';
|
|
115
|
+
|
|
116
|
+
// 6. Feature files from context/features/*.md
|
|
117
|
+
const features = [];
|
|
118
|
+
const featuresDir = join(crewDir, 'context', 'features');
|
|
119
|
+
if (existsSync(featuresDir)) {
|
|
120
|
+
try {
|
|
121
|
+
const files = readdirSync(featuresDir)
|
|
122
|
+
.filter(f => f.endsWith('.md') && f !== 'index.md')
|
|
123
|
+
.sort();
|
|
124
|
+
for (const f of files) {
|
|
125
|
+
const content = readFileOrNull(join(featuresDir, f));
|
|
126
|
+
if (content) {
|
|
127
|
+
features.push({ name: f.replace('.md', ''), content });
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
} catch {
|
|
131
|
+
// ignore
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return { sharedClaudeMd, roles, kanban, features, teamType, language, sessionFeatures };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Handle check_crew_context message — check if a project has .crew/ setup
|
|
140
|
+
* and return role metadata for the frontend.
|
|
141
|
+
*/
|
|
142
|
+
export function handleCheckCrewContext(msg) {
|
|
143
|
+
const { projectDir, requestId } = msg;
|
|
144
|
+
if (!projectDir) {
|
|
145
|
+
ctx.sendToServer({ type: 'crew_context_result', requestId, found: false });
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
const crewContext = loadCrewContext(projectDir);
|
|
149
|
+
if (!crewContext) {
|
|
150
|
+
ctx.sendToServer({ type: 'crew_context_result', requestId, found: false });
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
// Return a safe subset for the frontend (no full claudeMd content, just metadata)
|
|
154
|
+
ctx.sendToServer({
|
|
155
|
+
type: 'crew_context_result',
|
|
156
|
+
requestId,
|
|
157
|
+
found: true,
|
|
158
|
+
roles: crewContext.roles.map(r => ({
|
|
159
|
+
name: r.name,
|
|
160
|
+
displayName: r.displayName,
|
|
161
|
+
icon: r.icon,
|
|
162
|
+
description: r.description,
|
|
163
|
+
roleType: r.roleType,
|
|
164
|
+
isDecisionMaker: r.isDecisionMaker,
|
|
165
|
+
hasClaudeMd: !!(r.claudeMd && r.claudeMd.length > 0),
|
|
166
|
+
})),
|
|
167
|
+
teamType: crewContext.teamType,
|
|
168
|
+
language: crewContext.language,
|
|
169
|
+
featureCount: crewContext.features.length,
|
|
170
|
+
});
|
|
171
|
+
}
|