@yeaft/webchat-agent 1.0.76 → 1.0.78

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/crew/routing.js DELETED
@@ -1,951 +0,0 @@
1
- /**
2
- * Crew — 路由解析与执行
3
- * parseRoutes, executeRoute, buildRoutePrompt, dispatchToRole
4
- *
5
- * task-330c — Greedy-strip guard:
6
- * ⚠️ ROUTE-block stripping lives in `parseRoutes()` ONLY. Callers that
7
- * want the role's prose without ROUTE blocks must consume
8
- * `parseRoutes(text).displayBody` — never run a second
9
- * `text.replace(/---ROUTE---[\s\S]*$/g, '')` style strip on already
10
- * parser-cleaned text. A second strip would (a) re-process text
11
- * that no longer has ROUTE markers (no-op at best, miscut at worst),
12
- * (b) reintroduce the greedy tail-eating bug fixed by task-328.
13
- * The summary-fallback in role-output.js and the recent-routes
14
- * injector below both honour this contract.
15
- */
16
- import { join } from 'path';
17
- import { sendCrewMessage, sendCrewOutput, sendStatusUpdate } from './ui-messages.js';
18
- import { ensureTaskFile, appendTaskRecord, readTaskFile, updateKanban, readKanban, saveRoleWorkSummary } from './task-files.js';
19
- import { createRoleQuery, clearRoleSessionId } from './role-query.js';
20
- import { saveSessionMeta } from './persistence.js';
21
- import { recordRoutingEvent } from './routing-metrics.js';
22
- import ctx from '../context.js';
23
-
24
- /** Format role label */
25
- function roleLabel(r) {
26
- return r.icon ? `${r.icon} ${r.displayName}` : r.displayName;
27
- }
28
-
29
- /**
30
- * task-330c — Smart truncate for recent-routes / history snippets.
31
- *
32
- * Cuts at a sentence/line boundary when possible to avoid mid-sentence
33
- * truncation; falls back to a hard cut when no good boundary exists in
34
- * the candidate window. Always appends a marker so downstream readers
35
- * (LLM roles seeing recent-routes context) know the full text lives
36
- * elsewhere (feature file).
37
- *
38
- * Boundary detection: looks for the last period (`.` `。` `!` `?` `!` `?`)
39
- * or newline inside the window `[Math.floor(max*0.7), max)`. The 70% lower
40
- * bound is a quality floor — we don't want to cut so early that we lose
41
- * meaningful tail context just to hit a clean boundary.
42
- *
43
- * Idempotent: text already short enough is returned unchanged (no marker).
44
- *
45
- * @param {string} text — input string (may be any length)
46
- * @param {number} max — maximum chars before truncation
47
- * @returns {string} — original text or `<truncated>…(truncated, full in feature file)`
48
- */
49
- const TRUNCATE_MARKER = '…(truncated, full in feature file)';
50
- export function smartTruncate(text, max) {
51
- if (typeof text !== 'string') return '';
52
- if (!Number.isFinite(max) || max <= 0) return '';
53
- if (text.length <= max) return text;
54
-
55
- // Search window: prefer cuts in the last 30% of the limit.
56
- const windowStart = Math.floor(max * 0.7);
57
- const windowSlice = text.slice(windowStart, max);
58
- // Last sentence boundary in window — period family OR newline.
59
- // We accept a boundary char and cut AFTER it so the sentence stays whole.
60
- const BOUNDARY_RE = /[.。!?!?\n]/g;
61
- let bestIdx = -1;
62
- let m;
63
- while ((m = BOUNDARY_RE.exec(windowSlice)) !== null) {
64
- bestIdx = m.index;
65
- }
66
- let cutEnd;
67
- if (bestIdx !== -1) {
68
- cutEnd = windowStart + bestIdx + 1; // include the boundary char itself
69
- } else {
70
- cutEnd = max; // no boundary in window → hard cut
71
- }
72
- // Trim trailing whitespace from the cut piece for cleaner output.
73
- const head = text.slice(0, cutEnd).replace(/\s+$/, '');
74
- return `${head}${TRUNCATE_MARKER}`;
75
- }
76
-
77
- /**
78
- * Append text to content — works for both string and multimodal array content.
79
- * For arrays, appends to the last text block (or adds a new one).
80
- */
81
- function _appendTextToContent(content, text) {
82
- if (typeof content === 'string') return content + text;
83
- // Multimodal array: find last text block and append
84
- for (let i = content.length - 1; i >= 0; i--) {
85
- if (content[i].type === 'text') {
86
- content[i].text += text;
87
- return content;
88
- }
89
- }
90
- // No text block found — add one
91
- content.push({ type: 'text', text });
92
- return content;
93
- }
94
-
95
- /**
96
- * 从累积文本中解析所有 ROUTE 块(支持多 ROUTE + task 字段)。
97
- *
98
- * task-328 — Returns a structured result:
99
- * { routes, displayBody }
100
- *
101
- * - `routes` — Array<{to, summary, taskId, taskTitle}> (same shape as before)
102
- * - `displayBody` — original text MINUS the exact matched ROUTE ranges
103
- * (including any surrounding ```fence``` that wraps the
104
- * ROUTE block), preserving everything else verbatim.
105
- *
106
- * Backward compatibility: the returned object is also iterable as an array
107
- * of routes for any legacy caller that does `for (const r of parseRoutes(...))`
108
- * or `parseRoutes(...).length` — we attach `[Symbol.iterator]`, `length`, and
109
- * numeric index properties mirroring `routes`. New callers should use the
110
- * named `.routes` / `.displayBody` fields.
111
- *
112
- * Scope A — ROUTE parser tolerance (task-328):
113
- * (1) Markdown fence-wrapped ROUTE blocks are still parsed AND the fence
114
- * lines are stripped from displayBody (so the user doesn't see an
115
- * empty ```…```).
116
- * (2) END variants accepted: ---END_ROUTE--- / ---END ROUTE--- /
117
- * ---END--- / ---END:--- / ---END-ROUTE--- / ---endroute---.
118
- * (3) `to:` accepts: `to:` `to :` `to:` `TO:` with any casing.
119
- * (4) Phase 2 soft-end is a STRUCTURAL signal (blank line + `---`, or
120
- * `<kanban>` / `<recent-routes>` / `<task-context>`), NOT a bare blank
121
- * line — so multi-paragraph summaries are not truncated.
122
- * (5) Pre-pass: fenced code is MASKED (positions preserved) but a fence
123
- * that contains `---ROUTE---` is NOT masked — the ROUTE inside the
124
- * fence is the real one (matches what users write).
125
- *
126
- * Scope B — non-ROUTE body preservation (task-328):
127
- * - `displayBody` = original minus the EXACT matched ROUTE ranges.
128
- * No greedy "strip-to-EOF" anymore — post-ROUTE text survives.
129
- *
130
- * @param {string} text - Raw role output (may contain 0+ ROUTE blocks)
131
- * @returns {{ routes: Array<{to:string,summary:string,taskId:string|null,taskTitle:string|null}>, displayBody: string } & Iterable}
132
- */
133
- export function parseRoutes(text) {
134
- const input = typeof text === 'string' ? text : '';
135
- const routes = [];
136
- // Exact character ranges (in ORIGINAL `input`) to remove from displayBody.
137
- // Each entry: { start, end } — half-open, end exclusive.
138
- const strippedRanges = [];
139
-
140
- if (!input) return _wrapParseResult(routes, '', strippedRanges);
141
-
142
- // ─── Pre-pass §2: mask fenced code WITHOUT stripping from original ──
143
- // We build a boolean mask the same length as `input`. Fences are walked
144
- // left-to-right. A fence containing `---ROUTE---` is SKIPPED (not masked)
145
- // so the real ROUTE inside it can be parsed by Phase 1. Non-ROUTE fences
146
- // are masked so any ```example``` won't pollute Phase 1/2/3 matching.
147
- //
148
- // We also remember the start/end of each "ROUTE-carrying fence" so the
149
- // displayBody calculation can extend a ROUTE match to cover its fence
150
- // lines — otherwise the user would see an empty ```…``` left behind.
151
- const masked = _maskNonRouteFences(input);
152
- const maskedText = masked.text; // original chars or ' ' for masked regions
153
- const routeFences = masked.routeFences; // [{start, end, innerStart, innerEnd}, ...]
154
-
155
- // ─── Phase 1: Standard ROUTE blocks (with closing marker) ─────
156
- // Accept END variants: END_ROUTE | END ROUTE | END-ROUTE | END: | END | endroute
157
- // Body capture uses negative lookahead to avoid crossing another opener.
158
- // We run regex on `maskedText` so quoted examples (in non-ROUTE fences)
159
- // don't match, but we use match.index to index into the ORIGINAL input
160
- // when computing the strip range.
161
- const closedRegex = /---\s*ROUTE\s*---\s*\r?\n((?:(?!---\s*ROUTE\s*---)[\s\S])*?)---\s*(?:END[_ \-]?ROUTE|ENDROUTE|END)\s*:?\s*---/gi;
162
- let match;
163
- while ((match = closedRegex.exec(maskedText)) !== null) {
164
- const parsed = _parseRouteBlock(match[1]);
165
- let rangeStart = match.index;
166
- let rangeEnd = match.index + match[0].length;
167
- // §5: if this match lives inside a ROUTE-carrying fence, extend the
168
- // strip to cover the fence lines (so the UI doesn't see empty ```…```).
169
- const fence = routeFences.find(f => rangeStart >= f.innerStart && rangeEnd <= f.innerEnd);
170
- if (fence) { rangeStart = fence.start; rangeEnd = fence.end; }
171
- strippedRanges.push({ start: rangeStart, end: rangeEnd });
172
- if (parsed) routes.push(parsed);
173
- }
174
-
175
- // ─── Phase 2: Fallback — ROUTE block with no closing marker ──
176
- // Soft-end uses a STRUCTURAL signal, not a bare blank line. A summary
177
- // can span multiple paragraphs — it ends only when we see:
178
- // (a) another ---ROUTE--- opener, or
179
- // (b) EOF, or
180
- // (c) a structural separator after ≥2 consecutive newlines:
181
- // `\n\s*\n+---` (blank line + ---)
182
- // `\n\s*\n+<(kanban|recent-routes|task-context|EOF)`
183
- // (d) the 2048-char hard cap (safety valve for runaway blocks).
184
- const openRegex = /---\s*ROUTE\s*---\s*\r?\n/gi;
185
- while ((match = openRegex.exec(maskedText)) !== null) {
186
- const openStart = match.index;
187
- // Skip if this opener was already consumed by a Phase 1 match.
188
- if (strippedRanges.some(r => openStart >= r.start && openStart < r.end)) continue;
189
-
190
- const blockStart = openStart + match[0].length;
191
- // (a) next opener?
192
- const nextOpen = maskedText.indexOf('---ROUTE---', blockStart);
193
- const hardEnd = nextOpen !== -1 ? nextOpen : maskedText.length;
194
- const scope = maskedText.slice(blockStart, hardEnd);
195
-
196
- // (c) structural cutoff — scan for the first structural signal after
197
- // ≥2 consecutive newlines (blank line + structure).
198
- const SOFT_END_RE = /\n[ \t]*\n+(?:---(?!\s*ROUTE)|<(?:kanban|recent-routes|task-context)\b)/;
199
- const softMatch = scope.match(SOFT_END_RE);
200
- let blockEnd = hardEnd;
201
- if (softMatch && softMatch.index != null) {
202
- blockEnd = blockStart + softMatch.index;
203
- }
204
-
205
- // (d) 2048-char hard cap — protect against runaway unclosed blocks.
206
- const SUMMARY_CAP = 2048;
207
- if (blockEnd - blockStart > SUMMARY_CAP) blockEnd = blockStart + SUMMARY_CAP;
208
-
209
- const block = maskedText.slice(blockStart, blockEnd);
210
- const parsed = _parseRouteBlock(block);
211
-
212
- let rangeStart = openStart;
213
- let rangeEnd = blockEnd;
214
- // Extend to fence if wrapped.
215
- const fence = routeFences.find(f => rangeStart >= f.innerStart && rangeEnd <= f.innerEnd);
216
- if (fence) { rangeStart = fence.start; rangeEnd = fence.end; }
217
- strippedRanges.push({ start: rangeStart, end: rangeEnd });
218
- if (parsed) routes.push(parsed);
219
- }
220
-
221
- // ─── Phase 3: Shorthand — "ROUTE → target" / "ROUTE: target" ─
222
- // Only matches a single line and only outside any ROUTE block. We also
223
- // run this on maskedText so shorthand inside quoted fences is ignored.
224
- const shorthandRegex = /^ROUTE\s*[→:]\s*(\S+)[,:\s]*(.*)$/gm;
225
- while ((match = shorthandRegex.exec(maskedText)) !== null) {
226
- const pos = match.index;
227
- if (strippedRanges.some(r => pos >= r.start && pos < r.end)) continue;
228
-
229
- // Also skip if inside an open ---ROUTE--- block (even unclosed).
230
- const precedingText = maskedText.slice(0, pos);
231
- const lastRouteOpen = precedingText.search(/---\s*ROUTE\s*---(?![\s\S]*---\s*ROUTE\s*---)/i);
232
- const lastRouteOpenIdx = precedingText.lastIndexOf('---ROUTE---');
233
- const lastRouteCloseIdx = Math.max(
234
- precedingText.lastIndexOf('---END_ROUTE---'),
235
- precedingText.lastIndexOf('---END ROUTE---'),
236
- precedingText.lastIndexOf('---END-ROUTE---'),
237
- precedingText.lastIndexOf('---END---'),
238
- );
239
- if (lastRouteOpenIdx > lastRouteCloseIdx) continue;
240
- void lastRouteOpen; // silence unused
241
-
242
- const toRaw = match[1].trim().toLowerCase().replace(/[,;:!?。,;:!?]+$/, '');
243
- const summary = match[2] ? match[2].trim() : '[该角色未提供消息摘要]';
244
-
245
- routes.push({ to: toRaw, toList: [toRaw], summary, taskId: null, taskTitle: null });
246
- // Shorthand is a single line — strip the whole line.
247
- const lineEnd = maskedText.indexOf('\n', pos);
248
- strippedRanges.push({
249
- start: pos,
250
- end: lineEnd === -1 ? maskedText.length : lineEnd,
251
- });
252
- }
253
-
254
- const displayBody = _removeRanges(input, strippedRanges);
255
- return _wrapParseResult(routes, displayBody, strippedRanges);
256
- }
257
-
258
- /**
259
- * Wrap the parse result in an object that is ALSO iterable as an array
260
- * of routes (for legacy `for (const r of parseRoutes(x))` callers) and
261
- * supports `.length` / numeric index. New fields: `.routes`, `.displayBody`.
262
- * @private
263
- */
264
- function _wrapParseResult(routes, displayBody, rangesForDebug) {
265
- // Start from a real Array so `Array.isArray()` and iteration/indexing
266
- // "just work". Decorate with named fields that new callers prefer.
267
- const arr = routes.slice();
268
- Object.defineProperty(arr, 'routes', { value: routes, enumerable: false });
269
- Object.defineProperty(arr, 'displayBody', { value: displayBody, enumerable: false });
270
- Object.defineProperty(arr, 'strippedRanges', { value: rangesForDebug, enumerable: false });
271
- return arr;
272
- }
273
-
274
- /**
275
- * §2 helper — build a mask of `input` that replaces non-ROUTE fenced
276
- * code with spaces (length-preserving), and records the positions of
277
- * fences that DO contain a ROUTE opener (so Phase 1/2 can extend their
278
- * strip range to swallow the fence lines).
279
- *
280
- * @param {string} input
281
- * @returns {{ text: string, routeFences: Array<{start:number,end:number,innerStart:number,innerEnd:number}> }}
282
- * @private
283
- */
284
- function _maskNonRouteFences(input) {
285
- const FENCE_RE = /```[^\n]*\n([\s\S]*?)```/g;
286
- let m;
287
- let out = '';
288
- let lastIdx = 0;
289
- const routeFences = [];
290
- while ((m = FENCE_RE.exec(input)) !== null) {
291
- const fenceStart = m.index;
292
- const fenceEnd = m.index + m[0].length;
293
- const innerStart = fenceStart + m[0].indexOf('\n') + 1;
294
- const innerEnd = fenceEnd - 3; // strip trailing ```
295
- const fenceContent = m[1];
296
- const hasRoute = /---\s*ROUTE\s*---/i.test(fenceContent);
297
- // Copy unchanged text up to fence start
298
- out += input.slice(lastIdx, fenceStart);
299
- if (hasRoute) {
300
- // Keep the fence content intact so Phase 1 sees the ROUTE; record
301
- // the fence range for the displayBody extender.
302
- out += input.slice(fenceStart, fenceEnd);
303
- routeFences.push({ start: fenceStart, end: fenceEnd, innerStart, innerEnd });
304
- } else {
305
- // Mask entire fence (including markers) with spaces of equal length
306
- // so positions line up with the original string.
307
- out += ' '.repeat(fenceEnd - fenceStart);
308
- }
309
- lastIdx = fenceEnd;
310
- }
311
- out += input.slice(lastIdx);
312
- return { text: out, routeFences };
313
- }
314
-
315
- /**
316
- * Remove a list of (possibly overlapping) character ranges from `input`.
317
- * Also trims leading/trailing whitespace from the resulting blocks so the
318
- * displayBody doesn't keep lonely blank lines where a ROUTE used to be.
319
- *
320
- * @param {string} input
321
- * @param {Array<{start:number, end:number}>} ranges
322
- * @returns {string}
323
- * @private
324
- */
325
- function _removeRanges(input, ranges) {
326
- if (!ranges || ranges.length === 0) return input;
327
- // Merge overlapping/adjacent ranges.
328
- const sorted = ranges.slice().sort((a, b) => a.start - b.start);
329
- const merged = [sorted[0]];
330
- for (let i = 1; i < sorted.length; i++) {
331
- const prev = merged[merged.length - 1];
332
- const cur = sorted[i];
333
- if (cur.start <= prev.end) prev.end = Math.max(prev.end, cur.end);
334
- else merged.push({ ...cur });
335
- }
336
- // Build output by keeping the gaps between merged ranges.
337
- let out = '';
338
- let cursor = 0;
339
- for (const r of merged) {
340
- out += input.slice(cursor, r.start);
341
- cursor = r.end;
342
- }
343
- out += input.slice(cursor);
344
- // Collapse 3+ consecutive newlines (left by a removal) to a double newline.
345
- out = out.replace(/\n{3,}/g, '\n\n');
346
- return out.trim();
347
- }
348
-
349
- /**
350
- * Parse fields from a ROUTE block body (the content between ---ROUTE--- and ---END_ROUTE---).
351
- *
352
- * task-335 — multi-target `to:` support. The `to:` line is split on
353
- * common multi-target separators (`,`, `+`, `、`, `;`, `;`, ` and `) so
354
- * `to: rev-1, rev-3` / `to: rev-1 + rev-3` / `to: rev-1、rev-3` all
355
- * resolve to two distinct targets. Each target is normalised the same
356
- * way single-target was normalised before (strip parenthetical notes,
357
- * trailing punctuation, lowercase).
358
- *
359
- * Backward-compat shape: `route.to` is still a STRING (the first
360
- * target) so the 20+ existing tests that assert `route.to === 'dev-1'`
361
- * keep working. `route.toList` is the new authoritative list and is
362
- * always at least one element. `executeRoute` consumes `toList`.
363
- *
364
- * @param {string} block — raw block content
365
- * @returns {{ to: string, toList: string[], summary: string, taskId: string|null, taskTitle: string|null } | null}
366
- */
367
- function _parseRouteBlock(block) {
368
- // task-328 §3: tolerate Chinese full-width colon (`to:` / `task:` / `summary:`)
369
- // and stray whitespace before the colon (`to :`). All field separators accept
370
- // either ASCII `:` or Chinese `:`.
371
- const toMatch = block.match(/to\s*[::]\s*(.+)/i);
372
- if (!toMatch) return null;
373
-
374
- const toLine = toMatch[1].trim();
375
- // task-335: split multi-target `to:` lines. Separators recognised:
376
- // ASCII comma `,`, ASCII plus `+`, ASCII semicolon `;`, ` and `
377
- // Chinese full-width comma `,`, ideographic comma `、`, full-width
378
- // semicolon `;`, slash `/`. Whitespace around separators is fine.
379
- const MULTI_TARGET_SEP = /\s*(?:,|\+|;|、|,|;|\/|\s+and\s+)\s*/i;
380
- const rawTargets = toLine.split(MULTI_TARGET_SEP);
381
-
382
- const cleaned = [];
383
- const seen = new Set();
384
- for (const raw of rawTargets) {
385
- const trimmed = raw.trim().toLowerCase();
386
- if (!trimmed) continue;
387
- // ★ Clean a single target value: take only the first word (strip
388
- // parenthetical notes, trailing punctuation).
389
- // e.g. "pm (决策者)" → "pm", "dev-1 // main dev" → "dev-1"
390
- const oneWord = trimmed.split(/[\s(]/)[0].replace(/[,;:!?。,;:!?]+$/, '');
391
- if (!oneWord) continue;
392
- if (seen.has(oneWord)) continue;
393
- seen.add(oneWord);
394
- cleaned.push(oneWord);
395
- }
396
- if (cleaned.length === 0) return null;
397
-
398
- // ★ summary: match until next known field (task:/taskTitle:) or end of block.
399
- // Field separator accepts ASCII `:` or Chinese `:`.
400
- const summaryMatch = block.match(/summary\s*[::]\s*([\s\S]+?)(?=\n\s*(?:task|taskTitle)\s*[::]|$)/i);
401
- const taskMatch = block.match(/^task\s*[::]\s*(.+)/im);
402
- const taskTitleMatch = block.match(/^taskTitle\s*[::]\s*(.+)/im);
403
-
404
- let summary = summaryMatch ? summaryMatch[1].trim() : '';
405
-
406
- // ★ Bare-body fallback — PM / humans often omit the `summary:` label and
407
- // just write the message as free text AFTER the known fields. Collect
408
- // everything that is NOT a recognised field line as the body.
409
- if (!summary) {
410
- const KNOWN_FIELD = /^\s*(?:to|task|taskTitle|summary)\s*[::]/i;
411
- const bare = block
412
- .split(/\r?\n/)
413
- .filter(line => !KNOWN_FIELD.test(line))
414
- .join('\n')
415
- .trim();
416
- if (bare) summary = bare;
417
- }
418
-
419
- if (!summary) {
420
- summary = '[该角色未提供消息摘要]';
421
- }
422
-
423
- return {
424
- to: cleaned[0],
425
- toList: cleaned,
426
- summary,
427
- taskId: taskMatch ? taskMatch[1].trim() : null,
428
- taskTitle: taskTitleMatch ? taskTitleMatch[1].trim() : null
429
- };
430
- }
431
-
432
- /**
433
- * Resolve a ROUTE `to` value to an actual role name in the session.
434
- *
435
- * Resolution order:
436
- * 1. Exact match: `to` matches a role name directly (e.g. "dev-1")
437
- * 2. roleType match: `to` matches a role's roleType (e.g. "developer" → "dev-1")
438
- * 3. Short prefix match: `to` matches the SHORT_PREFIX of a roleType (e.g. "dev" → "dev-1")
439
- * 4. Same-group dispatch: if sender is in a multi-instance group (e.g. dev-1),
440
- * and `to` matches the roleType/prefix of another group (e.g. "reviewer"),
441
- * route to the instance with matching groupIndex (e.g. rev-1)
442
- *
443
- * For multi-instance matches (2/3), prefer the instance with the same groupIndex
444
- * as the sender. Falls back to the first instance if no groupIndex match.
445
- *
446
- * @param {string} to - raw route target from ROUTE block
447
- * @param {object} session - crew session
448
- * @param {string} [fromRole] - sending role name (for groupIndex matching)
449
- * @returns {string|null} resolved role name, or null if unresolvable
450
- */
451
- export function resolveRoleName(to, session, fromRole) {
452
- // 1. Exact match
453
- if (session.roles.has(to)) return to;
454
-
455
- // Build candidate list by roleType and short prefix
456
- const fromRoleConfig = fromRole ? session.roles.get(fromRole) : null;
457
- const fromGroupIndex = fromRoleConfig?.groupIndex || 0;
458
-
459
- let candidates = [];
460
-
461
- for (const [name, config] of session.roles) {
462
- // 2. roleType match (e.g. "developer" → dev-1, dev-2, dev-3)
463
- if (config.roleType === to) {
464
- candidates.push({ name, groupIndex: config.groupIndex || 0 });
465
- }
466
- // 3. Short prefix match (e.g. "dev" → developer roleType → dev-1)
467
- // Match if the role name starts with `to-` (e.g. "dev" matches "dev-1", "dev-2")
468
- else if (name.startsWith(to + '-') && /^\d+$/.test(name.slice(to.length + 1))) {
469
- candidates.push({ name, groupIndex: config.groupIndex || 0 });
470
- }
471
- }
472
-
473
- // 4. displayName match (e.g. "乔布斯" → pm)
474
- if (candidates.length === 0) {
475
- for (const [name, config] of session.roles) {
476
- if (config.displayName && config.displayName.toLowerCase() === to) {
477
- candidates.push({ name, groupIndex: config.groupIndex || 0 });
478
- }
479
- }
480
- }
481
-
482
- // 5. name-displayName compound match (e.g. "pm-乔布斯" → pm)
483
- // Claude sometimes concatenates role name + display name with a hyphen
484
- if (candidates.length === 0) {
485
- for (const [name, config] of session.roles) {
486
- if (to.startsWith(name + '-') && to.length > name.length + 1) {
487
- candidates.push({ name, groupIndex: config.groupIndex || 0 });
488
- }
489
- }
490
- }
491
-
492
- if (candidates.length === 0) return null;
493
-
494
- // 6. Prefer same groupIndex as sender
495
- if (fromGroupIndex > 0) {
496
- const sameGroup = candidates.find(c => c.groupIndex === fromGroupIndex);
497
- if (sameGroup) return sameGroup.name;
498
- }
499
-
500
- // Fall back to first candidate
501
- return candidates[0].name;
502
- }
503
-
504
- /**
505
- * 执行路由
506
- *
507
- * task-335 — multi-target fan-out. `route.toList` (string[]) is the
508
- * authoritative list of targets; `route.to` is kept as the first target
509
- * for backward-compat with downstream consumers (kanban / sendCrewOutput
510
- * still see a single primary `to`). Each target is dispatched as if it
511
- * were its own ROUTE: self-route reject is per-target (one bad target
512
- * does not kill the whole fan-out), unknown-target fallback is
513
- * per-target, dispatch + kanban update + sendCrewOutput happen
514
- * per-target.
515
- *
516
- * Side-effects done ONCE per call (not per target):
517
- * - taskId fallback resolution
518
- * - auto-resume from paused/stopped
519
- * - state-stopped metric
520
- *
521
- * @param {Array<{mimeType, data}>} [turnImages] - auto-attached images from the turn (max 3)
522
- */
523
- export async function executeRoute(session, fromRole, route, turnImages = []) {
524
- let { summary, taskId, taskTitle } = route;
525
-
526
- // task-335: normalise to a list. Accept legacy callers that still pass
527
- // `to: 'pm'` (string) without `toList`.
528
- let toList = Array.isArray(route.toList) && route.toList.length > 0
529
- ? route.toList.slice()
530
- : (typeof route.to === 'string' ? [route.to] : []);
531
- if (toList.length === 0) {
532
- console.warn(`[Crew] executeRoute called with no targets (fromRole=${fromRole})`);
533
- return;
534
- }
535
-
536
- // task-330b §B item 1: state-stopped metric — message arrived while
537
- // session was paused/stopped. Behaviour (auto-resume) is unchanged for
538
- // backward compat; this is observer-only. Recorded ONCE per fan-out.
539
- if (session.status === 'paused' || session.status === 'stopped') {
540
- recordRoutingEvent(session, 'state-stopped', {
541
- fromRole,
542
- toRole: toList.join(','),
543
- taskId: taskId || null,
544
- note: `session.status=${session.status} at executeRoute entry`,
545
- });
546
- }
547
-
548
- // Auto-resume: paused/stopped → running (route execution means work should continue)
549
- if (session.status === 'paused' || session.status === 'stopped') {
550
- console.log(`[Crew] Auto-resuming session from ${session.status} to running (route from ${fromRole} to ${toList.join(',')})`);
551
- session.status = 'running';
552
- sendStatusUpdate(session);
553
- }
554
-
555
- // ─── task-321: taskId fallback chain ─────────────────────────────
556
- // When a ROUTE omits `task:` (shorthand, bare dispatch, human messages,
557
- // PM forgetting the field), fall back to:
558
- // (a) the sender's currentTask.taskId
559
- // (b) the most recent non-system entry in session.messageHistory
560
- // This keeps prev-* / designer / architect / shorthand messages from
561
- // becoming taskId=null orphans that never appear on any feature card.
562
- // Done ONCE per fan-out — all targets share the same inferred taskId.
563
- if (!taskId) {
564
- const fromRoleState = session.roleStates?.get(fromRole);
565
- if (fromRoleState?.currentTask?.taskId) {
566
- taskId = fromRoleState.currentTask.taskId;
567
- taskTitle = taskTitle || fromRoleState.currentTask.taskTitle || null;
568
- } else if (Array.isArray(session.messageHistory) && session.messageHistory.length > 0) {
569
- for (let i = session.messageHistory.length - 1; i >= 0; i--) {
570
- const h = session.messageHistory[i];
571
- if (h && h.from !== 'system' && h.taskId) {
572
- taskId = h.taskId;
573
- break;
574
- }
575
- }
576
- }
577
- // Mirror the fallback back into the route object so downstream
578
- // consumers (dispatchToRole / sendCrewOutput) see the inferred id.
579
- if (taskId) {
580
- route.taskId = taskId;
581
- if (taskTitle) route.taskTitle = taskTitle;
582
- }
583
- }
584
-
585
- // ─── Fan-out over targets ─────────────────────────────────────────
586
- for (const to of toList) {
587
- await _dispatchOneTarget(session, fromRole, to, summary, taskId, taskTitle, toList.length, turnImages);
588
- }
589
- }
590
-
591
- /**
592
- * task-335 helper — dispatch ONE resolved target. Carries the per-target
593
- * self-route reject + unknown-target fallback + kanban + UI emit +
594
- * actual dispatchToRole. Failures on one target do NOT abort the rest.
595
- *
596
- * @param {number} batchSize — how many targets in the parent fan-out
597
- * (used for log clarity; not behavioural)
598
- * @private
599
- */
600
- async function _dispatchOneTarget(session, fromRole, to, summary, taskId, taskTitle, batchSize, turnImages) {
601
- // ─── task-330a §A + task-330b §B: self-route hard-reject + metric ───
602
- // 福勒 Final Spec §A — `route.to` 等同于发送方时直接拒绝,不消费 turn、
603
- // 不写 kanban、不 dispatch、不 round++(round 已由 role-output 计数)。
604
- // 解析顺序:先尝试用 resolveRoleName 还原 alias(pm/dev/displayName/
605
- // pm-乔布斯 等),命中即比较;未命中则退回原始字符串大小写不敏感比较。
606
- // 拒绝时:先写 330b 的 routing-metrics.json 持久化 metric(observer 路径),
607
- // 再 emit 330a 的 sendCrewMessage UI 卡片,最后 return(不消费 turn)。
608
- // alias self-route 漏记 metric 已记入 PM backlog 作 follow-up(330b 的
609
- // raw 比较 `to === fromRole` 仅命中字面相同的情况;alias 形式由 330a
610
- // 的 isSelf 兜底,但 330b 的 raw 检查保留为快速路径 + 兼容)。
611
- //
612
- // task-335: now per-target. A self-route to one target in a multi-
613
- // target ROUTE only rejects THAT target; the other targets fan out
614
- // normally.
615
- if (to !== 'human') {
616
- const resolvedSelfCheck = resolveRoleName(to, session, fromRole);
617
- const isSelf = resolvedSelfCheck === fromRole
618
- || (typeof to === 'string' && to.toLowerCase() === String(fromRole).toLowerCase());
619
- if (isSelf) {
620
- console.warn(`[Crew] Self-route rejected: ${fromRole} → ${to} (taskId=${taskId || '-'}, batch=${batchSize})`);
621
- recordRoutingEvent(session, 'self-route', {
622
- fromRole,
623
- toRole: to,
624
- taskId: taskId || null,
625
- note: 'route.to === fromRole at executeRoute entry (rejected by §A)',
626
- });
627
- try {
628
- sendCrewMessage({
629
- type: 'routing-metrics',
630
- sessionId: session.id,
631
- event: 'route_rejected',
632
- reason: 'self-route',
633
- fromRole,
634
- to,
635
- taskId: taskId || null,
636
- timestamp: Date.now(),
637
- });
638
- } catch (e) {
639
- console.warn('[Crew] Failed to emit routing-metrics:', e.message);
640
- }
641
- try {
642
- sendCrewMessage({
643
- type: 'crew_route_rejected',
644
- sessionId: session.id,
645
- fromRole,
646
- to,
647
- reason: 'self-route',
648
- message: `自路由被拒绝:${fromRole} 不能给自己发消息。请改用 task_close(taskId, summary) 关闭任务,或 role_standby(role) 进入待命,或选择其他角色作为 ROUTE 目标。`,
649
- taskId: taskId || null,
650
- });
651
- } catch (e) {
652
- console.warn('[Crew] Failed to emit crew_route_rejected:', e.message);
653
- }
654
- // Do NOT decrement session.round — role-output.js already incremented
655
- // it for this whole turn batch; one rejected route doesn't undo the
656
- // turn (other routes/targets in the same batch may still be valid).
657
- return;
658
- }
659
- }
660
-
661
- // Task 文件自动管理(fire-and-forget) — per-target so each target gets
662
- // a kanban entry assigned to it.
663
- if (taskId && summary) {
664
- const fromRoleConfig = session.roles.get(fromRole);
665
- // task-321: Auto-create feature file even when a non-PM role is the
666
- // first to mention the taskId. Any role carrying a taskId (PM, devs,
667
- // reviewers, designer, architect) now triggers creation — not just PM
668
- // with explicit taskTitle. appendTaskRecord itself also creates the
669
- // file if missing, so this is a best-effort fast path.
670
- const effectiveTitle = taskTitle
671
- || session.features?.get(taskId)?.taskTitle
672
- || null;
673
- if (effectiveTitle && to !== 'human') {
674
- ensureTaskFile(session, taskId, effectiveTitle, fromRoleConfig?.isDecisionMaker ? to : fromRole, summary)
675
- .catch(e => console.warn(`[Crew] Failed to create task file ${taskId}:`, e.message));
676
- }
677
- appendTaskRecord(session, taskId, fromRole, summary, { taskTitle: effectiveTitle })
678
- .catch(e => console.warn(`[Crew] Failed to append task record ${taskId}:`, e.message));
679
-
680
- // 更新工作看板:推断状态
681
- const { getMessages } = await import('../crew-i18n.js');
682
- const m = getMessages(session.language || 'zh-CN');
683
- // ★ Use resolveRoleName for kanban status lookup too
684
- const resolvedKanbanTo = resolveRoleName(to, session, fromRole);
685
- const toRoleConfig = session.roles.get(resolvedKanbanTo || to);
686
- let status = m.kanbanStatusDev;
687
- if (toRoleConfig) {
688
- switch (toRoleConfig.roleType) {
689
- case 'reviewer': status = m.kanbanStatusReview; break;
690
- case 'product-reviewer': status = m.kanbanStatusProductReview; break;
691
- default:
692
- if (toRoleConfig.isDecisionMaker) status = m.kanbanStatusDecision;
693
- }
694
- }
695
- updateKanban(session, {
696
- taskId, taskTitle, assignee: resolvedKanbanTo || to,
697
- status, summary
698
- }).catch(e => console.warn(`[Crew] Failed to update kanban:`, e.message));
699
- }
700
-
701
- // 发送路由消息(UI 显示) — per-target so the transcript shows one
702
- // route card per fan-out leg, mirroring how single-target ROUTEs render.
703
- sendCrewOutput(session, fromRole, 'route', null, {
704
- routeTo: to, routeSummary: summary,
705
- taskId: taskId || undefined,
706
- taskTitle: taskTitle || undefined,
707
- // ★ Auto-attach turn images (base64) — server will cache and convert to fileId/previewToken
708
- routeImages: turnImages.length > 0 ? turnImages.map(img => ({
709
- mimeType: img.mimeType,
710
- data: img.data
711
- })) : undefined
712
- });
713
-
714
- // 路由到 human
715
- if (to === 'human') {
716
- session.status = 'waiting_human';
717
- session.waitingHumanContext = {
718
- fromRole,
719
- reason: 'requested',
720
- message: summary
721
- };
722
- sendCrewMessage({
723
- type: 'crew_human_needed',
724
- sessionId: session.id,
725
- fromRole,
726
- reason: 'requested',
727
- message: summary
728
- });
729
- sendStatusUpdate(session);
730
- // Status changed to waiting_human — persist
731
- saveSessionMeta(session).catch(e => console.warn('[Crew] Failed to save after →human:', e.message));
732
- return;
733
- }
734
-
735
- // 路由到指定角色
736
- const resolvedTo = resolveRoleName(to, session, fromRole);
737
- if (resolvedTo) {
738
- if (session.humanMessageQueue.length > 0) {
739
- const { processHumanQueue } = await import('./human-interaction.js');
740
- await processHumanQueue(session);
741
- } else {
742
- const taskPrompt = buildRoutePrompt(fromRole, summary, session, turnImages);
743
- await dispatchToRole(session, resolvedTo, taskPrompt, fromRole, taskId, taskTitle);
744
- }
745
- } else {
746
- const availableRoles = Array.from(session.roles.keys()).join(', ');
747
- console.warn(`[Crew] Unknown route target: ${to} (available: ${availableRoles})`);
748
- const errorMsg = `路由目标 "${to}" 不存在。可用角色: ${availableRoles}\n来自 ${fromRole} 的消息: ${summary}`;
749
- await dispatchToRole(session, session.decisionMaker, errorMsg, 'system');
750
- }
751
- }
752
-
753
- /**
754
- * 构建路由转发的 prompt(支持多模态 — 自动附加 turn 截图)
755
- * @param {Array<{mimeType, data}>} [turnImages] - auto-attached images
756
- * @returns {string|Array} text string, or multimodal content array when images present
757
- */
758
- export function buildRoutePrompt(fromRole, summary, session, turnImages = []) {
759
- const fromRoleConfig = session.roles.get(fromRole);
760
- const fromName = fromRoleConfig ? roleLabel(fromRoleConfig) : fromRole;
761
- const text = `来自 ${fromName} 的消息:\n${summary}\n\n请开始你的工作。完成后通过 ROUTE 块传递给下一个角色。`;
762
-
763
- if (turnImages.length === 0) return text;
764
-
765
- // Build multimodal content: images first, then text
766
- const blocks = [];
767
- for (const img of turnImages) {
768
- blocks.push({
769
- type: 'image',
770
- source: { type: 'base64', media_type: img.mimeType, data: img.data }
771
- });
772
- }
773
- blocks.push({ type: 'text', text });
774
- return blocks;
775
- }
776
-
777
- /**
778
- * 向角色发送消息
779
- */
780
- export async function dispatchToRole(session, roleName, content, fromSource, taskId, taskTitle) {
781
- // Only block during initialization (roles not ready yet)
782
- if (session.status === 'initializing') {
783
- console.log(`[Crew] Session initializing, skipping dispatch to ${roleName}`);
784
- return;
785
- }
786
-
787
- // Auto-resume: paused/stopped → running (dispatch means work should continue)
788
- if (session.status === 'paused' || session.status === 'stopped') {
789
- console.log(`[Crew] Auto-resuming session from ${session.status} to running (dispatch to ${roleName})`);
790
- session.status = 'running';
791
- sendStatusUpdate(session);
792
- }
793
-
794
- let roleState = session.roleStates.get(roleName);
795
-
796
- // 如果角色没有 query 实例,创建一个(支持 resume)
797
- if (!roleState || !roleState.query || !roleState.inputStream) {
798
- roleState = await createRoleQuery(session, roleName);
799
- }
800
-
801
- // 设置 task
802
- // task-321: keep currentTask sticky. A new taskId updates it; a dispatch
803
- // without taskId preserves the previous currentTask so subsequent
804
- // sendCrewOutput calls (which read roleState.currentTask.taskId) keep
805
- // attaching to the right feature card — instead of falling back to null
806
- // the moment the sender omits the `task:` field.
807
- if (taskId) {
808
- roleState.currentTask = { taskId, taskTitle: taskTitle || roleState.currentTask?.taskTitle || null };
809
- }
810
-
811
- // Task 上下文注入
812
- const effectiveTaskId = taskId || roleState.currentTask?.taskId;
813
- if (effectiveTaskId) {
814
- const taskContent = await readTaskFile(session, effectiveTaskId);
815
- if (taskContent) {
816
- const ctx = `\n\n---\n<task-context file=".crew/context/features/${effectiveTaskId}.md">\n${taskContent}\n</task-context>`;
817
- content = _appendTextToContent(content, ctx);
818
- }
819
- }
820
-
821
- // 看板上下文注入(角色重启后知道全局状态)
822
- {
823
- const kanbanContent = await readKanban(session);
824
- if (kanbanContent) {
825
- const ctx = `\n\n---\n<kanban file=".crew/context/kanban.md">\n${kanbanContent}\n</kanban>`;
826
- content = _appendTextToContent(content, ctx);
827
- }
828
- }
829
-
830
- // 最近路由消息注入(帮助 clear 后的角色恢复上下文)
831
- // task-330c: each entry is smart-truncated to 400 chars at a sentence
832
- // boundary (period/newline) so we don't slice key info mid-sentence.
833
- // The full content lives in the feature file — the marker tells the
834
- // role where to look if they need more context. The pre-stored
835
- // `m.content` was already truncated to 200 (history step below) until
836
- // task-330c bumped it to 400 + smart boundary.
837
- // ⚠️ DO NOT pass `m.content` through any greedy `.replace(/.../g, '')`
838
- // here — it has already been derived from displayBody at message
839
- // time (parser-stripped), and a second strip would re-process
840
- // text that no longer holds ROUTE markers. See _appendHistory below.
841
- if (session.messageHistory.length > 0) {
842
- const recentRoutes = session.messageHistory
843
- .filter(m => m.from !== 'system')
844
- .slice(-5)
845
- .map(m => `[${m.from} → ${m.to}${m.taskId ? ` (${m.taskId})` : ''}] ${smartTruncate(m.content, 400)}`)
846
- .join('\n');
847
- if (recentRoutes) {
848
- const ctx = `\n\n---\n<recent-routes>\n${recentRoutes}\n</recent-routes>`;
849
- content = _appendTextToContent(content, ctx);
850
- }
851
- }
852
-
853
- // 记录消息历史
854
- // task-330c: cap raised 200 → 400 to match the recent-routes injection
855
- // window. Keeping the pre-store cap at 200 would pin every entry below
856
- // smartTruncate's 400 threshold, making the smart-truncate boundary cut
857
- // a permanent no-op in production. 400 here lets longer messages flow
858
- // into history; smartTruncate trims them at sentence boundaries when
859
- // injected into <recent-routes>.
860
- const historyContent = typeof content === 'string'
861
- ? content.substring(0, 400)
862
- : (Array.isArray(content) ? content.filter(b => b.type === 'text').map(b => b.text).join('').substring(0, 400) + (content.some(b => b.type === 'image') ? ' [+images]' : '') : '...');
863
- session.messageHistory.push({
864
- from: fromSource,
865
- to: roleName,
866
- content: historyContent,
867
- taskId: taskId || roleState.currentTask?.taskId || null,
868
- timestamp: Date.now()
869
- });
870
-
871
- // DISABLED (2026-03): Opus 4.6 has 200k context. Claude Code handles its own compaction.
872
- // Keeping code for reference; re-enable if we ever need custom crew pre-send compact.
873
- // ★ Pre-send compact check: estimate total tokens and clear+rebuild if needed
874
- // const autoCompactThreshold = ctx.CONFIG?.autoCompactThreshold || 100000;
875
- // const lastInputTokens = roleState.lastInputTokens || 0;
876
- // const estimatedNewTokens = Math.ceil((typeof content === 'string' ? content.length : 0) / 3);
877
- // const estimatedTotal = lastInputTokens + estimatedNewTokens;
878
- //
879
- // if (lastInputTokens > 0 && estimatedTotal > autoCompactThreshold) {
880
- // console.log(`[Crew] Pre-send compact for ${roleName}: estimated ${estimatedTotal} tokens (last: ${lastInputTokens} + new: ~${estimatedNewTokens}) exceeds threshold ${autoCompactThreshold}`);
881
- //
882
- // // Save work summary before clearing (use lastTurnText since accumulatedText is cleared after result)
883
- // await saveRoleWorkSummary(session, roleName, roleState.lastTurnText || roleState.accumulatedText || '').catch(e =>
884
- // console.warn(`[Crew] Failed to save work summary for ${roleName}:`, e.message));
885
- //
886
- // // Clear role session and rebuild
887
- // await clearRoleSessionId(session.sharedDir, roleName);
888
- // roleState.claudeSessionId = null;
889
- //
890
- // if (roleState.abortController) roleState.abortController.abort();
891
- // roleState.query = null;
892
- // roleState.inputStream = null;
893
- //
894
- // sendCrewMessage({
895
- // type: 'crew_role_cleared',
896
- // sessionId: session.id,
897
- // role: roleName,
898
- // contextPercentage: Math.round((lastInputTokens / (ctx.CONFIG?.maxContextTokens || 128000)) * 100),
899
- // reason: 'pre_send_compact'
900
- // });
901
- //
902
- // // Recreate the query (fresh Claude process)
903
- // roleState = await createRoleQuery(session, roleName);
904
- // }
905
-
906
- // P1-4: 守卫 stream.enqueue — stream 可能已被 abort 关闭
907
- roleState.lastDispatchContent = content;
908
- roleState.lastDispatchFrom = fromSource;
909
- roleState.lastDispatchTaskId = taskId || null;
910
- roleState.lastDispatchTaskTitle = taskTitle || null;
911
- roleState.turnActive = true;
912
- roleState.accumulatedText = '';
913
- try {
914
- if (roleState.inputStream && !roleState.inputStream.isDone) {
915
- roleState.inputStream.enqueue({
916
- type: 'user',
917
- message: { role: 'user', content }
918
- });
919
- } else {
920
- console.warn(`[Crew] Cannot enqueue to ${roleName}: stream closed or missing, recreating`);
921
- roleState = await createRoleQuery(session, roleName);
922
- roleState.lastDispatchContent = content;
923
- roleState.lastDispatchFrom = fromSource;
924
- roleState.lastDispatchTaskId = taskId || null;
925
- roleState.lastDispatchTaskTitle = taskTitle || null;
926
- roleState.turnActive = true;
927
- roleState.accumulatedText = '';
928
- roleState.inputStream.enqueue({
929
- type: 'user',
930
- message: { role: 'user', content }
931
- });
932
- }
933
- } catch (enqueueErr) {
934
- console.error(`[Crew] Failed to enqueue to ${roleName}:`, enqueueErr.message);
935
- // Recreate query and retry once
936
- roleState = await createRoleQuery(session, roleName);
937
- roleState.lastDispatchContent = content;
938
- roleState.lastDispatchFrom = fromSource;
939
- roleState.lastDispatchTaskId = taskId || null;
940
- roleState.lastDispatchTaskTitle = taskTitle || null;
941
- roleState.turnActive = true;
942
- roleState.accumulatedText = '';
943
- roleState.inputStream.enqueue({
944
- type: 'user',
945
- message: { role: 'user', content }
946
- });
947
- }
948
-
949
- sendStatusUpdate(session);
950
- console.log(`[Crew] Dispatched to ${roleName} from ${fromSource}${taskId ? ` (task: ${taskId})` : ''}`);
951
- }