@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 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
- const rolePlayConfig = rolePlayEntry
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
- const results = await Promise.allSettled(pending.map(({ fromRole, route }) =>
172
- executeRoute(session, fromRole, route)
173
- ));
174
- for (const r of results) {
175
- if (r.status === 'rejected') {
176
- console.warn(`[Crew] Pending route replay failed:`, r.reason);
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
- roleState.inputStream.enqueue({
90
- type: 'user',
91
- message: { role: 'user', content: message }
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;
@@ -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
- roleState.inputStream.enqueue({
238
- type: 'user',
239
- message: { role: 'user', content }
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "0.1.75",
3
+ "version": "0.1.77",
4
4
  "description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
5
5
  "main": "index.js",
6
6
  "type": "module",
package/roleplay.js CHANGED
@@ -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 = 4096;
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
+ }