@yeaft/webchat-agent 0.1.75 → 0.1.77
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 +9 -1
- package/conversation.js +25 -2
- package/crew/control.js +31 -6
- package/crew/human-interaction.js +25 -4
- package/crew/role-query.js +38 -0
- package/crew/routing.js +36 -5
- package/package.json +1 -1
- package/roleplay.js +229 -4
package/claude.js
CHANGED
|
@@ -8,7 +8,9 @@ import {
|
|
|
8
8
|
detectRoleSignal,
|
|
9
9
|
processRolePlayRoutes,
|
|
10
10
|
buildRouteEventMessage,
|
|
11
|
-
getRolePlayRouteState
|
|
11
|
+
getRolePlayRouteState,
|
|
12
|
+
refreshCrewContext,
|
|
13
|
+
writeBackRouteContext
|
|
12
14
|
} from './roleplay.js';
|
|
13
15
|
|
|
14
16
|
/**
|
|
@@ -447,6 +449,12 @@ async function processClaudeOutput(conversationId, claudeQuery, state) {
|
|
|
447
449
|
ctx.sendToServer(routeEvent);
|
|
448
450
|
}
|
|
449
451
|
|
|
452
|
+
// ★ Write-back: persist route task info to .crew/context/features
|
|
453
|
+
const taskRoutes = routes.filter(r => r.taskId && r.summary);
|
|
454
|
+
if (taskRoutes.length > 0 && state.workDir) {
|
|
455
|
+
writeBackRouteContext(state.workDir, taskRoutes, rpSession.currentRole || 'unknown', rpSession);
|
|
456
|
+
}
|
|
457
|
+
|
|
450
458
|
// Send route state update
|
|
451
459
|
const routeState = getRolePlayRouteState(conversationId);
|
|
452
460
|
if (routeState) {
|
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, initRolePlayRouteState, loadCrewContext } from './roleplay.js';
|
|
5
|
+
import { rolePlaySessions, saveRolePlayIndex, removeRolePlaySession, loadRolePlayIndex, validateRolePlayConfig, initRolePlayRouteState, loadCrewContext, refreshCrewContext, initCrewContextMtimes } from './roleplay.js';
|
|
6
6
|
|
|
7
7
|
// Restore persisted roleplay sessions on module load (agent startup)
|
|
8
8
|
loadRolePlayIndex();
|
|
@@ -256,10 +256,21 @@ export async function resumeConversation(msg) {
|
|
|
256
256
|
// Claude 进程会在用户发送第一条消息时启动 (见 handleUserInput)
|
|
257
257
|
// Restore rolePlayConfig from persisted rolePlaySessions if available
|
|
258
258
|
const rolePlayEntry = rolePlaySessions.get(conversationId);
|
|
259
|
-
|
|
259
|
+
let rolePlayConfig = rolePlayEntry
|
|
260
260
|
? { roles: rolePlayEntry.roles, teamType: rolePlayEntry.teamType, language: rolePlayEntry.language }
|
|
261
261
|
: null;
|
|
262
262
|
|
|
263
|
+
// ★ RolePlay resume: refresh .crew context to get latest kanban/features
|
|
264
|
+
if (rolePlayConfig && rolePlayEntry) {
|
|
265
|
+
const crewContext = loadCrewContext(effectiveWorkDir);
|
|
266
|
+
if (crewContext) {
|
|
267
|
+
rolePlayConfig.crewContext = crewContext;
|
|
268
|
+
// Initialize mtime snapshot (without re-loading) so subsequent refreshes can detect changes
|
|
269
|
+
initCrewContextMtimes(effectiveWorkDir, rolePlayEntry);
|
|
270
|
+
console.log(`[Resume] RolePlay: refreshed .crew context (${crewContext.features.length} features)`);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
263
274
|
ctx.conversations.set(conversationId, {
|
|
264
275
|
query: null,
|
|
265
276
|
inputStream: null,
|
|
@@ -483,6 +494,18 @@ export async function handleUserInput(msg) {
|
|
|
483
494
|
const resumeSessionId = claudeSessionId || state?.claudeSessionId || null;
|
|
484
495
|
const effectiveWorkDir = workDir || state?.workDir || ctx.CONFIG.workDir;
|
|
485
496
|
|
|
497
|
+
// ★ RolePlay: refresh .crew context before starting a new query
|
|
498
|
+
// so the appendSystemPrompt has the latest kanban/features
|
|
499
|
+
if (state?.rolePlayConfig) {
|
|
500
|
+
const rpSession = rolePlaySessions.get(conversationId);
|
|
501
|
+
if (rpSession) {
|
|
502
|
+
const refreshed = refreshCrewContext(effectiveWorkDir, rpSession, state);
|
|
503
|
+
if (refreshed) {
|
|
504
|
+
console.log(`[SDK] RolePlay: .crew context refreshed before query start`);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
486
509
|
console.log(`[SDK] Starting Claude for ${conversationId}, resume: ${resumeSessionId || 'none'}`);
|
|
487
510
|
state = await startClaudeQuery(conversationId, effectiveWorkDir, resumeSessionId);
|
|
488
511
|
}
|
package/crew/control.js
CHANGED
|
@@ -66,6 +66,11 @@ export async function handleCrewControl(msg) {
|
|
|
66
66
|
async function clearSingleRole(session, roleName) {
|
|
67
67
|
const roleState = session.roleStates.get(roleName);
|
|
68
68
|
|
|
69
|
+
// P0-1: 清除该角色在 humanMessageQueue 中的待处理消息,防止幽灵 ROUTE 执行
|
|
70
|
+
if (session.humanMessageQueue.length > 0) {
|
|
71
|
+
session.humanMessageQueue = session.humanMessageQueue.filter(m => m.target !== roleName);
|
|
72
|
+
}
|
|
73
|
+
|
|
69
74
|
if (roleState) {
|
|
70
75
|
// 保存工作摘要到 task file(与 context_exceeded clear 一致)
|
|
71
76
|
if (roleState.accumulatedText) {
|
|
@@ -73,9 +78,21 @@ async function clearSingleRole(session, roleName) {
|
|
|
73
78
|
console.warn(`[Crew] Failed to save work summary for ${roleName}:`, e.message));
|
|
74
79
|
}
|
|
75
80
|
|
|
81
|
+
// P1-3: abort 并等待 query iterator 退出,避免 abort+dispatch 竞态
|
|
76
82
|
if (roleState.abortController) {
|
|
77
83
|
roleState.abortController.abort();
|
|
78
84
|
}
|
|
85
|
+
// 等待 query 完成(iterator 会因 AbortError 退出)
|
|
86
|
+
if (roleState.query) {
|
|
87
|
+
try {
|
|
88
|
+
// Drain the iterator — it will throw AbortError and exit
|
|
89
|
+
// eslint-disable-next-line no-empty
|
|
90
|
+
for await (const _ of roleState.query) {}
|
|
91
|
+
} catch {
|
|
92
|
+
// Expected: AbortError or other cleanup errors
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
79
96
|
roleState.query = null;
|
|
80
97
|
roleState.inputStream = null;
|
|
81
98
|
roleState.turnActive = false;
|
|
@@ -86,6 +103,10 @@ async function clearSingleRole(session, roleName) {
|
|
|
86
103
|
roleState.lastDispatchFrom = null;
|
|
87
104
|
roleState.lastDispatchTaskId = null;
|
|
88
105
|
roleState.lastDispatchTaskTitle = null;
|
|
106
|
+
// P0-3: 重置 UI 相关状态,防止显示过时信息
|
|
107
|
+
roleState.currentTask = null;
|
|
108
|
+
roleState.currentTool = null;
|
|
109
|
+
roleState.lastTurnText = '';
|
|
89
110
|
}
|
|
90
111
|
|
|
91
112
|
await clearRoleSessionId(session.sharedDir, roleName);
|
|
@@ -168,12 +189,12 @@ async function resumeSession(session) {
|
|
|
168
189
|
const pending = session.pendingRoutes.slice();
|
|
169
190
|
session.pendingRoutes = [];
|
|
170
191
|
console.log(`[Crew] Replaying ${pending.length} pending route(s)`);
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
console.warn(`[Crew] Pending route replay failed:`,
|
|
192
|
+
// P1-5: 串行执行 pending routes,防止多个 route 指向同一角色时并发 dispatch
|
|
193
|
+
for (const { fromRole, route } of pending) {
|
|
194
|
+
try {
|
|
195
|
+
await executeRoute(session, fromRole, route);
|
|
196
|
+
} catch (err) {
|
|
197
|
+
console.warn(`[Crew] Pending route replay failed:`, err);
|
|
177
198
|
}
|
|
178
199
|
}
|
|
179
200
|
return;
|
|
@@ -338,6 +359,9 @@ async function stopAll(session) {
|
|
|
338
359
|
* 清空 session
|
|
339
360
|
*/
|
|
340
361
|
async function clearSession(session) {
|
|
362
|
+
// P1-2: 先重置 _processingHumanQueue,防止后续消息处理被阻塞
|
|
363
|
+
session._processingHumanQueue = false;
|
|
364
|
+
|
|
341
365
|
for (const [roleName, roleState] of session.roleStates) {
|
|
342
366
|
if (roleState.abortController) {
|
|
343
367
|
roleState.abortController.abort();
|
|
@@ -350,6 +374,7 @@ async function clearSession(session) {
|
|
|
350
374
|
await clearRoleSessionId(session.sharedDir, roleName);
|
|
351
375
|
}
|
|
352
376
|
|
|
377
|
+
// P1-1: humanMessageQueue 在这里清空,确保不会有幽灵消息在 clear 后被处理
|
|
353
378
|
session.messageHistory = [];
|
|
354
379
|
session.uiMessages = [];
|
|
355
380
|
session.humanMessageQueue = [];
|
|
@@ -86,10 +86,31 @@ export async function handleCrewHumanInput(msg) {
|
|
|
86
86
|
const { createRoleQuery } = await import('./role-query.js');
|
|
87
87
|
roleState = await createRoleQuery(session, target);
|
|
88
88
|
}
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
89
|
+
// P1-4: 守卫 stream.enqueue
|
|
90
|
+
try {
|
|
91
|
+
if (roleState.inputStream && !roleState.inputStream.isDone) {
|
|
92
|
+
roleState.inputStream.enqueue({
|
|
93
|
+
type: 'user',
|
|
94
|
+
message: { role: 'user', content: message }
|
|
95
|
+
});
|
|
96
|
+
} else {
|
|
97
|
+
console.warn(`[Crew] Skill dispatch: stream closed for ${target}, recreating`);
|
|
98
|
+
const { createRoleQuery } = await import('./role-query.js');
|
|
99
|
+
roleState = await createRoleQuery(session, target);
|
|
100
|
+
roleState.inputStream.enqueue({
|
|
101
|
+
type: 'user',
|
|
102
|
+
message: { role: 'user', content: message }
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
} catch (enqueueErr) {
|
|
106
|
+
console.error(`[Crew] Skill dispatch enqueue failed for ${target}:`, enqueueErr.message);
|
|
107
|
+
const { createRoleQuery } = await import('./role-query.js');
|
|
108
|
+
roleState = await createRoleQuery(session, target);
|
|
109
|
+
roleState.inputStream.enqueue({
|
|
110
|
+
type: 'user',
|
|
111
|
+
message: { role: 'user', content: message }
|
|
112
|
+
});
|
|
113
|
+
}
|
|
93
114
|
sendStatusUpdate(session);
|
|
94
115
|
console.log(`[Crew] Skill command dispatched to ${target}: ${message}`);
|
|
95
116
|
return;
|
package/crew/role-query.js
CHANGED
|
@@ -84,10 +84,48 @@ export function classifyRoleError(error) {
|
|
|
84
84
|
// Role Query Management
|
|
85
85
|
// =====================================================================
|
|
86
86
|
|
|
87
|
+
// P1-6: Per-role mutex to prevent concurrent createRoleQuery calls creating orphan processes
|
|
88
|
+
const _roleQueryLocks = new Map();
|
|
89
|
+
|
|
87
90
|
/**
|
|
88
91
|
* 为角色创建持久 query 实例
|
|
89
92
|
*/
|
|
90
93
|
export async function createRoleQuery(session, roleName) {
|
|
94
|
+
const lockKey = `${session.id}:${roleName}`;
|
|
95
|
+
|
|
96
|
+
// P1-6: 如果该角色已有 pending 的 createRoleQuery,等待它完成
|
|
97
|
+
if (_roleQueryLocks.has(lockKey)) {
|
|
98
|
+
console.log(`[Crew] Waiting for existing createRoleQuery lock on ${roleName}`);
|
|
99
|
+
try {
|
|
100
|
+
await _roleQueryLocks.get(lockKey);
|
|
101
|
+
} catch {
|
|
102
|
+
// Previous attempt failed, proceed with new creation
|
|
103
|
+
}
|
|
104
|
+
// 锁释放后,检查是否已经有可用的 query
|
|
105
|
+
const existing = session.roleStates.get(roleName);
|
|
106
|
+
if (existing?.query && existing?.inputStream && !existing.inputStream.isDone) {
|
|
107
|
+
console.log(`[Crew] Reusing existing query for ${roleName} after lock release`);
|
|
108
|
+
return existing;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const promise = _createRoleQueryInner(session, roleName);
|
|
113
|
+
_roleQueryLocks.set(lockKey, promise);
|
|
114
|
+
try {
|
|
115
|
+
const result = await promise;
|
|
116
|
+
return result;
|
|
117
|
+
} finally {
|
|
118
|
+
// 只删除自己的 lock(如果后续调用覆盖了,不误删)
|
|
119
|
+
if (_roleQueryLocks.get(lockKey) === promise) {
|
|
120
|
+
_roleQueryLocks.delete(lockKey);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* createRoleQuery 内部实现
|
|
127
|
+
*/
|
|
128
|
+
async function _createRoleQueryInner(session, roleName) {
|
|
91
129
|
const role = session.roles.get(roleName);
|
|
92
130
|
if (!role) throw new Error(`Role not found: ${roleName}`);
|
|
93
131
|
|
package/crew/routing.js
CHANGED
|
@@ -227,17 +227,48 @@ export async function dispatchToRole(session, roleName, content, fromSource, tas
|
|
|
227
227
|
roleState = await createRoleQuery(session, roleName);
|
|
228
228
|
}
|
|
229
229
|
|
|
230
|
-
//
|
|
230
|
+
// P1-4: 守卫 stream.enqueue — stream 可能已被 abort 关闭
|
|
231
231
|
roleState.lastDispatchContent = content;
|
|
232
232
|
roleState.lastDispatchFrom = fromSource;
|
|
233
233
|
roleState.lastDispatchTaskId = taskId || null;
|
|
234
234
|
roleState.lastDispatchTaskTitle = taskTitle || null;
|
|
235
235
|
roleState.turnActive = true;
|
|
236
236
|
roleState.accumulatedText = '';
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
237
|
+
try {
|
|
238
|
+
if (roleState.inputStream && !roleState.inputStream.isDone) {
|
|
239
|
+
roleState.inputStream.enqueue({
|
|
240
|
+
type: 'user',
|
|
241
|
+
message: { role: 'user', content }
|
|
242
|
+
});
|
|
243
|
+
} else {
|
|
244
|
+
console.warn(`[Crew] Cannot enqueue to ${roleName}: stream closed or missing, recreating`);
|
|
245
|
+
roleState = await createRoleQuery(session, roleName);
|
|
246
|
+
roleState.lastDispatchContent = content;
|
|
247
|
+
roleState.lastDispatchFrom = fromSource;
|
|
248
|
+
roleState.lastDispatchTaskId = taskId || null;
|
|
249
|
+
roleState.lastDispatchTaskTitle = taskTitle || null;
|
|
250
|
+
roleState.turnActive = true;
|
|
251
|
+
roleState.accumulatedText = '';
|
|
252
|
+
roleState.inputStream.enqueue({
|
|
253
|
+
type: 'user',
|
|
254
|
+
message: { role: 'user', content }
|
|
255
|
+
});
|
|
256
|
+
}
|
|
257
|
+
} catch (enqueueErr) {
|
|
258
|
+
console.error(`[Crew] Failed to enqueue to ${roleName}:`, enqueueErr.message);
|
|
259
|
+
// Recreate query and retry once
|
|
260
|
+
roleState = await createRoleQuery(session, roleName);
|
|
261
|
+
roleState.lastDispatchContent = content;
|
|
262
|
+
roleState.lastDispatchFrom = fromSource;
|
|
263
|
+
roleState.lastDispatchTaskId = taskId || null;
|
|
264
|
+
roleState.lastDispatchTaskTitle = taskTitle || null;
|
|
265
|
+
roleState.turnActive = true;
|
|
266
|
+
roleState.accumulatedText = '';
|
|
267
|
+
roleState.inputStream.enqueue({
|
|
268
|
+
type: 'user',
|
|
269
|
+
message: { role: 'user', content }
|
|
270
|
+
});
|
|
271
|
+
}
|
|
241
272
|
|
|
242
273
|
sendStatusUpdate(session);
|
|
243
274
|
console.log(`[Crew] Dispatched to ${roleName} from ${fromSource}${taskId ? ` (task: ${taskId})` : ''}`);
|
package/package.json
CHANGED
package/roleplay.js
CHANGED
|
@@ -9,7 +9,8 @@
|
|
|
9
9
|
|
|
10
10
|
import { join } from 'path';
|
|
11
11
|
import { homedir } from 'os';
|
|
12
|
-
import { readFileSync, writeFileSync, existsSync, renameSync, readdirSync } from 'fs';
|
|
12
|
+
import { readFileSync, writeFileSync, existsSync, renameSync, readdirSync, statSync } from 'fs';
|
|
13
|
+
import { promises as fsp } from 'fs';
|
|
13
14
|
import { parseRoutes } from './crew/routing.js';
|
|
14
15
|
|
|
15
16
|
const ROLEPLAY_INDEX_PATH = join(homedir(), '.claude', 'roleplay-sessions.json');
|
|
@@ -26,8 +27,8 @@ export const rolePlaySessions = new Map();
|
|
|
26
27
|
export function saveRolePlayIndex() {
|
|
27
28
|
const data = [];
|
|
28
29
|
for (const [id, session] of rolePlaySessions) {
|
|
29
|
-
// Only persist core fields, skip runtime route state
|
|
30
|
-
const { _routeInitialized, currentRole, features, round, roleStates, waitingHuman, waitingHumanContext, ...core } = session;
|
|
30
|
+
// Only persist core fields, skip runtime route state and mtime snapshots
|
|
31
|
+
const { _routeInitialized, _crewContextMtimes, currentRole, features, round, roleStates, waitingHuman, waitingHumanContext, ...core } = session;
|
|
31
32
|
data.push({ id, ...core });
|
|
32
33
|
}
|
|
33
34
|
try {
|
|
@@ -218,7 +219,7 @@ function deduplicateRoles(sessionRoles, roleClaudes) {
|
|
|
218
219
|
const ALLOWED_TEAM_TYPES = ['dev', 'writing', 'trading', 'video', 'custom'];
|
|
219
220
|
const MAX_ROLE_NAME_LEN = 64;
|
|
220
221
|
const MAX_DISPLAY_NAME_LEN = 128;
|
|
221
|
-
const MAX_CLAUDE_MD_LEN =
|
|
222
|
+
const MAX_CLAUDE_MD_LEN = 8192;
|
|
222
223
|
const MAX_ROLES = 10;
|
|
223
224
|
|
|
224
225
|
/**
|
|
@@ -716,3 +717,227 @@ function buildCrewContextBlock(crewContext, isZh) {
|
|
|
716
717
|
|
|
717
718
|
return sections.join('\n\n');
|
|
718
719
|
}
|
|
720
|
+
|
|
721
|
+
// ---------------------------------------------------------------------------
|
|
722
|
+
// .crew context refresh (mtime-based change detection)
|
|
723
|
+
// ---------------------------------------------------------------------------
|
|
724
|
+
|
|
725
|
+
/**
|
|
726
|
+
* Get mtime of a file, or 0 if it doesn't exist.
|
|
727
|
+
* @param {string} filePath
|
|
728
|
+
* @returns {number} mtime in ms
|
|
729
|
+
*/
|
|
730
|
+
function getMtimeMs(filePath) {
|
|
731
|
+
try {
|
|
732
|
+
return statSync(filePath).mtimeMs;
|
|
733
|
+
} catch {
|
|
734
|
+
return 0;
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
/**
|
|
739
|
+
* Collect mtimes for all .crew context files that matter for RolePlay.
|
|
740
|
+
* Returns a map of { relativePath → mtimeMs }.
|
|
741
|
+
*
|
|
742
|
+
* @param {string} projectDir
|
|
743
|
+
* @returns {Map<string, number>}
|
|
744
|
+
*/
|
|
745
|
+
function collectCrewContextMtimes(projectDir) {
|
|
746
|
+
const crewDir = join(projectDir, '.crew');
|
|
747
|
+
const mtimes = new Map();
|
|
748
|
+
|
|
749
|
+
// Shared CLAUDE.md
|
|
750
|
+
mtimes.set('CLAUDE.md', getMtimeMs(join(crewDir, 'CLAUDE.md')));
|
|
751
|
+
|
|
752
|
+
// context/kanban.md
|
|
753
|
+
mtimes.set('context/kanban.md', getMtimeMs(join(crewDir, 'context', 'kanban.md')));
|
|
754
|
+
|
|
755
|
+
// context/features/*.md
|
|
756
|
+
const featuresDir = join(crewDir, 'context', 'features');
|
|
757
|
+
if (existsSync(featuresDir)) {
|
|
758
|
+
try {
|
|
759
|
+
const files = readdirSync(featuresDir).filter(f => f.endsWith('.md') && f !== 'index.md');
|
|
760
|
+
for (const f of files) {
|
|
761
|
+
mtimes.set(`context/features/${f}`, getMtimeMs(join(featuresDir, f)));
|
|
762
|
+
}
|
|
763
|
+
} catch { /* ignore */ }
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
// session.json (roles may change)
|
|
767
|
+
mtimes.set('session.json', getMtimeMs(join(crewDir, 'session.json')));
|
|
768
|
+
|
|
769
|
+
return mtimes;
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
/**
|
|
773
|
+
* Check if .crew context has changed since last snapshot.
|
|
774
|
+
*
|
|
775
|
+
* @param {Map<string, number>} oldMtimes - previous mtime snapshot
|
|
776
|
+
* @param {Map<string, number>} newMtimes - current mtime snapshot
|
|
777
|
+
* @returns {boolean} true if any file has been added, removed, or modified
|
|
778
|
+
*/
|
|
779
|
+
function hasCrewContextChanged(oldMtimes, newMtimes) {
|
|
780
|
+
if (!oldMtimes) return true; // first check → always refresh
|
|
781
|
+
|
|
782
|
+
// Defensive: if oldMtimes was deserialized from JSON (plain object, not Map),
|
|
783
|
+
// treat as stale and force refresh
|
|
784
|
+
if (!(oldMtimes instanceof Map)) return true;
|
|
785
|
+
|
|
786
|
+
// Check for new or modified files
|
|
787
|
+
for (const [path, mtime] of newMtimes) {
|
|
788
|
+
if (!oldMtimes.has(path) || oldMtimes.get(path) !== mtime) return true;
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
// Check for deleted files
|
|
792
|
+
for (const path of oldMtimes.keys()) {
|
|
793
|
+
if (!newMtimes.has(path)) return true;
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
return false;
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
/**
|
|
800
|
+
* Initialize the mtime snapshot for a RolePlay session without reloading context.
|
|
801
|
+
* Use this when the caller has already loaded crewContext (e.g. resume path)
|
|
802
|
+
* to avoid a redundant disk read.
|
|
803
|
+
*
|
|
804
|
+
* @param {string} projectDir - absolute path to project root
|
|
805
|
+
* @param {object} rpSession - rolePlaySessions entry
|
|
806
|
+
*/
|
|
807
|
+
export function initCrewContextMtimes(projectDir, rpSession) {
|
|
808
|
+
if (!projectDir || !existsSync(join(projectDir, '.crew'))) return;
|
|
809
|
+
rpSession._crewContextMtimes = collectCrewContextMtimes(projectDir);
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
/**
|
|
813
|
+
* Refresh .crew context for a RolePlay session if files have changed.
|
|
814
|
+
* Updates the session's crewContext and returns true if refreshed.
|
|
815
|
+
*
|
|
816
|
+
* Call this before building the system prompt (on resume, or before each turn).
|
|
817
|
+
*
|
|
818
|
+
* @param {string} projectDir - absolute path to project root
|
|
819
|
+
* @param {object} rpSession - rolePlaySessions entry
|
|
820
|
+
* @param {object} convState - ctx.conversations entry (has rolePlayConfig)
|
|
821
|
+
* @returns {boolean} true if context was refreshed
|
|
822
|
+
*/
|
|
823
|
+
export function refreshCrewContext(projectDir, rpSession, convState) {
|
|
824
|
+
if (!projectDir || !existsSync(join(projectDir, '.crew'))) return false;
|
|
825
|
+
|
|
826
|
+
const newMtimes = collectCrewContextMtimes(projectDir);
|
|
827
|
+
|
|
828
|
+
// Compare with stored snapshot
|
|
829
|
+
if (!hasCrewContextChanged(rpSession._crewContextMtimes, newMtimes)) {
|
|
830
|
+
return false; // no change
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
// Reload
|
|
834
|
+
const crewContext = loadCrewContext(projectDir);
|
|
835
|
+
if (!crewContext) return false;
|
|
836
|
+
|
|
837
|
+
// Update session and convState
|
|
838
|
+
rpSession._crewContextMtimes = newMtimes;
|
|
839
|
+
|
|
840
|
+
if (convState && convState.rolePlayConfig) {
|
|
841
|
+
convState.rolePlayConfig.crewContext = crewContext;
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
console.log(`[RolePlay] Crew context refreshed from ${projectDir} (${crewContext.features.length} features, kanban: ${crewContext.kanban ? 'yes' : 'no'})`);
|
|
845
|
+
return true;
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
// ---------------------------------------------------------------------------
|
|
849
|
+
// .crew context write-back (RolePlay → .crew/context)
|
|
850
|
+
// ---------------------------------------------------------------------------
|
|
851
|
+
|
|
852
|
+
// Write lock for atomic write-back
|
|
853
|
+
let _writeBackLock = Promise.resolve();
|
|
854
|
+
|
|
855
|
+
/**
|
|
856
|
+
* Atomic write: write to temp file then rename.
|
|
857
|
+
* @param {string} filePath
|
|
858
|
+
* @param {string} content
|
|
859
|
+
*/
|
|
860
|
+
async function atomicWrite(filePath, content) {
|
|
861
|
+
const tmpPath = filePath + '.tmp.' + Date.now();
|
|
862
|
+
try {
|
|
863
|
+
await fsp.writeFile(tmpPath, content);
|
|
864
|
+
await fsp.rename(tmpPath, filePath);
|
|
865
|
+
} catch (e) {
|
|
866
|
+
// Clean up temp file on failure
|
|
867
|
+
try { await fsp.unlink(tmpPath); } catch { /* ignore */ }
|
|
868
|
+
throw e;
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
/**
|
|
873
|
+
* Write back RolePlay route output to .crew/context files.
|
|
874
|
+
* Called after processRolePlayRoutes detects ROUTE blocks with task info.
|
|
875
|
+
*
|
|
876
|
+
* - Creates/updates context/features/{taskId}.md with route summary
|
|
877
|
+
* - Serialized via write lock to prevent concurrent corruption
|
|
878
|
+
*
|
|
879
|
+
* NOTE: The atomic write (tmp→rename) prevents partial writes within this
|
|
880
|
+
* process, and the serial lock prevents intra-process races. However, if a
|
|
881
|
+
* Crew session in a separate process writes the same file concurrently,
|
|
882
|
+
* a TOCTOU race is possible (read-then-write is not locked across processes).
|
|
883
|
+
* In practice this is acceptable: Crew and RolePlay rarely write the same
|
|
884
|
+
* task file simultaneously, and the worst case is a lost append (not corruption).
|
|
885
|
+
*
|
|
886
|
+
* @param {string} projectDir - absolute path to project root
|
|
887
|
+
* @param {Array<{to: string, summary: string, taskId?: string, taskTitle?: string}>} routes
|
|
888
|
+
* @param {string} fromRole - name of the role that produced the output
|
|
889
|
+
* @param {object} rpSession - rolePlaySessions entry
|
|
890
|
+
*/
|
|
891
|
+
export function writeBackRouteContext(projectDir, routes, fromRole, rpSession) {
|
|
892
|
+
if (!projectDir || !routes || routes.length === 0) return;
|
|
893
|
+
|
|
894
|
+
const crewDir = join(projectDir, '.crew');
|
|
895
|
+
if (!existsSync(crewDir)) return;
|
|
896
|
+
|
|
897
|
+
const doWriteBack = async () => {
|
|
898
|
+
const featuresDir = join(crewDir, 'context', 'features');
|
|
899
|
+
|
|
900
|
+
for (const route of routes) {
|
|
901
|
+
const { taskId, taskTitle, summary, to } = route;
|
|
902
|
+
if (!taskId || !summary) continue;
|
|
903
|
+
|
|
904
|
+
// ★ Sanitize taskId: only allow alphanumeric, hyphens, underscores
|
|
905
|
+
// Prevents path traversal (e.g. "../" in taskId from Claude output)
|
|
906
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(taskId)) {
|
|
907
|
+
console.warn(`[RolePlay] Write-back rejected: invalid taskId "${taskId}"`);
|
|
908
|
+
continue;
|
|
909
|
+
}
|
|
910
|
+
|
|
911
|
+
try {
|
|
912
|
+
await fsp.mkdir(featuresDir, { recursive: true });
|
|
913
|
+
const filePath = join(featuresDir, `${taskId}.md`);
|
|
914
|
+
|
|
915
|
+
let content;
|
|
916
|
+
try {
|
|
917
|
+
content = await fsp.readFile(filePath, 'utf-8');
|
|
918
|
+
} catch {
|
|
919
|
+
// File doesn't exist — create it
|
|
920
|
+
const isZh = rpSession.language === 'zh-CN';
|
|
921
|
+
content = `# ${isZh ? 'Feature' : 'Feature'}: ${taskTitle || taskId}\n- task-id: ${taskId}\n\n## ${isZh ? '工作记录' : 'Work Record'}\n`;
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
// Append the route record
|
|
925
|
+
const fromRoleConfig = rpSession.roles?.find(r => r.name === fromRole);
|
|
926
|
+
const fromLabel = fromRoleConfig
|
|
927
|
+
? (fromRoleConfig.icon ? `${fromRoleConfig.icon} ${fromRoleConfig.displayName}` : fromRoleConfig.displayName)
|
|
928
|
+
: fromRole;
|
|
929
|
+
const now = new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' });
|
|
930
|
+
const record = `\n### ${fromLabel} → ${to} - ${now}\n${summary}\n`;
|
|
931
|
+
|
|
932
|
+
await atomicWrite(filePath, content + record);
|
|
933
|
+
console.log(`[RolePlay] Write-back: task ${taskId} updated (${fromRole} → ${to})`);
|
|
934
|
+
} catch (e) {
|
|
935
|
+
console.warn(`[RolePlay] Write-back failed for ${taskId}:`, e.message);
|
|
936
|
+
}
|
|
937
|
+
}
|
|
938
|
+
};
|
|
939
|
+
|
|
940
|
+
// Serialize write-backs
|
|
941
|
+
_writeBackLock = _writeBackLock.then(doWriteBack, doWriteBack);
|
|
942
|
+
return _writeBackLock;
|
|
943
|
+
}
|