@triflux/remote 10.0.0-alpha.1
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/hub/pipe.mjs +579 -0
- package/hub/public/dashboard.html +355 -0
- package/hub/public/tray-icon.ico +0 -0
- package/hub/public/tray-icon.png +0 -0
- package/hub/server.mjs +1124 -0
- package/hub/store-adapter.mjs +851 -0
- package/hub/store.mjs +897 -0
- package/hub/team/agent-map.json +11 -0
- package/hub/team/ansi.mjs +379 -0
- package/hub/team/backend.mjs +90 -0
- package/hub/team/cli/commands/attach.mjs +37 -0
- package/hub/team/cli/commands/control.mjs +43 -0
- package/hub/team/cli/commands/debug.mjs +74 -0
- package/hub/team/cli/commands/focus.mjs +53 -0
- package/hub/team/cli/commands/interrupt.mjs +36 -0
- package/hub/team/cli/commands/kill.mjs +37 -0
- package/hub/team/cli/commands/list.mjs +24 -0
- package/hub/team/cli/commands/send.mjs +37 -0
- package/hub/team/cli/commands/start/index.mjs +106 -0
- package/hub/team/cli/commands/start/parse-args.mjs +130 -0
- package/hub/team/cli/commands/start/start-headless.mjs +109 -0
- package/hub/team/cli/commands/start/start-in-process.mjs +40 -0
- package/hub/team/cli/commands/start/start-mux.mjs +73 -0
- package/hub/team/cli/commands/start/start-wt.mjs +69 -0
- package/hub/team/cli/commands/status.mjs +87 -0
- package/hub/team/cli/commands/stop.mjs +31 -0
- package/hub/team/cli/commands/task.mjs +30 -0
- package/hub/team/cli/commands/tasks.mjs +13 -0
- package/hub/team/cli/help.mjs +42 -0
- package/hub/team/cli/index.mjs +41 -0
- package/hub/team/cli/manifest.mjs +29 -0
- package/hub/team/cli/render.mjs +30 -0
- package/hub/team/cli/services/attach-fallback.mjs +54 -0
- package/hub/team/cli/services/hub-client.mjs +208 -0
- package/hub/team/cli/services/member-selector.mjs +30 -0
- package/hub/team/cli/services/native-control.mjs +117 -0
- package/hub/team/cli/services/runtime-mode.mjs +62 -0
- package/hub/team/cli/services/state-store.mjs +48 -0
- package/hub/team/cli/services/task-model.mjs +30 -0
- package/hub/team/dashboard-anchor.mjs +14 -0
- package/hub/team/dashboard-layout.mjs +33 -0
- package/hub/team/dashboard-open.mjs +153 -0
- package/hub/team/dashboard.mjs +274 -0
- package/hub/team/handoff.mjs +303 -0
- package/hub/team/headless.mjs +1149 -0
- package/hub/team/native-supervisor.mjs +392 -0
- package/hub/team/native.mjs +649 -0
- package/hub/team/nativeProxy.mjs +681 -0
- package/hub/team/orchestrator.mjs +161 -0
- package/hub/team/pane.mjs +153 -0
- package/hub/team/psmux.mjs +1354 -0
- package/hub/team/routing.mjs +223 -0
- package/hub/team/session.mjs +611 -0
- package/hub/team/shared.mjs +13 -0
- package/hub/team/staleState.mjs +361 -0
- package/hub/team/tui-lite.mjs +380 -0
- package/hub/team/tui-viewer.mjs +463 -0
- package/hub/team/tui.mjs +1245 -0
- package/hub/tools.mjs +554 -0
- package/hub/tray.mjs +376 -0
- package/hub/workers/claude-worker.mjs +475 -0
- package/hub/workers/codex-mcp.mjs +504 -0
- package/hub/workers/delegator-mcp.mjs +1076 -0
- package/hub/workers/factory.mjs +21 -0
- package/hub/workers/gemini-worker.mjs +373 -0
- package/hub/workers/interface.mjs +52 -0
- package/hub/workers/worker-utils.mjs +104 -0
- package/package.json +31 -0
|
@@ -0,0 +1,649 @@
|
|
|
1
|
+
// hub/team/native.mjs — Claude Native Teams 래퍼
|
|
2
|
+
// teammate 프롬프트 템플릿 + 팀 설정 빌더
|
|
3
|
+
//
|
|
4
|
+
// Claude Code 네이티브 Agent Teams (CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1)
|
|
5
|
+
// 환경에서 teammate를 Codex/Gemini CLI 래퍼로 구성하는 유틸리티.
|
|
6
|
+
// SKILL.md가 인라인 프롬프트를 사용하므로, 이 모듈은 CLI(tfx multi --native)에서
|
|
7
|
+
// 팀 설정을 프로그래밍적으로 생성할 때 사용한다.
|
|
8
|
+
|
|
9
|
+
import * as fs from "node:fs/promises";
|
|
10
|
+
import os from "node:os";
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
|
|
13
|
+
const ROUTE_SCRIPT = "~/.claude/scripts/tfx-route.sh";
|
|
14
|
+
export const SLIM_WRAPPER_SUBAGENT_TYPE = "slim-wrapper";
|
|
15
|
+
/** scout 역할 기본 설정 — read-only 탐색 전용 */
|
|
16
|
+
export const SCOUT_ROLE_CONFIG = {
|
|
17
|
+
cli: "codex",
|
|
18
|
+
role: "scientist",
|
|
19
|
+
mcp_profile: "analyze",
|
|
20
|
+
maxIterations: 2,
|
|
21
|
+
readOnly: true,
|
|
22
|
+
};
|
|
23
|
+
const ROUTE_LOG_RE = /\[tfx-route\]/i;
|
|
24
|
+
const ROUTE_COMMAND_RE = /(?:^|[\s"'`])(?:bash\s+)?(?:[^"'`\s]*\/)?tfx-route\.sh\b/i;
|
|
25
|
+
const ROUTE_PROMPT_RE = /tfx-route\.sh/i;
|
|
26
|
+
const DIRECT_TOOL_BYPASS_RE = /\b(?:Read|Edit|Write)\s*\(/;
|
|
27
|
+
|
|
28
|
+
function inferWorkerIndex(agentName = "") {
|
|
29
|
+
const match = /(\d+)(?!.*\d)/.exec(agentName);
|
|
30
|
+
if (!match) return null;
|
|
31
|
+
const index = Number(match[1]);
|
|
32
|
+
return Number.isInteger(index) && index > 0 ? index : null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function buildRouteEnvPrefix(agentName, workerIndex, searchTool) {
|
|
36
|
+
const effectiveWorkerIndex = Number.isInteger(workerIndex) && workerIndex > 0
|
|
37
|
+
? workerIndex
|
|
38
|
+
: inferWorkerIndex(agentName);
|
|
39
|
+
|
|
40
|
+
let envPrefix = "";
|
|
41
|
+
if (effectiveWorkerIndex) envPrefix += ` TFX_WORKER_INDEX="${effectiveWorkerIndex}"`;
|
|
42
|
+
if (searchTool) envPrefix += ` TFX_SEARCH_TOOL="${searchTool}"`;
|
|
43
|
+
return envPrefix;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* slim-wrapper 커스텀 subagent 사양.
|
|
48
|
+
* Claude Code custom subagent(`.claude/agents/slim-wrapper.md`)와 짝을 이룬다.
|
|
49
|
+
*
|
|
50
|
+
* @param {'codex'|'gemini'} cli
|
|
51
|
+
* @param {object} opts
|
|
52
|
+
* @returns {{name:string, cli:string, subagent_type:string, prompt:string}}
|
|
53
|
+
*/
|
|
54
|
+
export function buildSlimWrapperAgent(cli, opts = {}) {
|
|
55
|
+
return {
|
|
56
|
+
name: opts.agentName || `${cli}-wrapper`,
|
|
57
|
+
cli,
|
|
58
|
+
subagent_type: SLIM_WRAPPER_SUBAGENT_TYPE,
|
|
59
|
+
prompt: buildSlimWrapperPrompt(cli, opts),
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* slim-wrapper 로그에서 tfx-route.sh 경유 흔적을 판정한다.
|
|
65
|
+
* route stderr prefix(`[tfx-route]`) 또는 Bash command trace를 근거로 본다.
|
|
66
|
+
*
|
|
67
|
+
* @param {object} input
|
|
68
|
+
* @param {string} [input.promptText]
|
|
69
|
+
* @param {string} [input.stdoutText]
|
|
70
|
+
* @param {string} [input.stderrText]
|
|
71
|
+
* @returns {{
|
|
72
|
+
* expectedRouteInvocation: boolean,
|
|
73
|
+
* promptMentionsRoute: boolean,
|
|
74
|
+
* sawRouteCommand: boolean,
|
|
75
|
+
* sawRouteLog: boolean,
|
|
76
|
+
* sawDirectToolBypass: boolean,
|
|
77
|
+
* usedRoute: boolean,
|
|
78
|
+
* abnormal: boolean,
|
|
79
|
+
* reason: string|null,
|
|
80
|
+
* slopDetected: boolean,
|
|
81
|
+
* }}
|
|
82
|
+
*/
|
|
83
|
+
export function verifySlimWrapperRouteExecution(input = {}) {
|
|
84
|
+
const promptText = String(input.promptText || "");
|
|
85
|
+
const stdoutText = String(input.stdoutText || "");
|
|
86
|
+
const stderrText = String(input.stderrText || "");
|
|
87
|
+
const combinedLogs = `${stdoutText}\n${stderrText}`;
|
|
88
|
+
const promptMentionsRoute = ROUTE_PROMPT_RE.test(promptText);
|
|
89
|
+
const sawRouteCommand = ROUTE_COMMAND_RE.test(combinedLogs);
|
|
90
|
+
const sawRouteLog = ROUTE_LOG_RE.test(combinedLogs);
|
|
91
|
+
const sawDirectToolBypass = DIRECT_TOOL_BYPASS_RE.test(stdoutText);
|
|
92
|
+
const usedRoute = sawRouteCommand || sawRouteLog;
|
|
93
|
+
const expectedRouteInvocation = promptMentionsRoute;
|
|
94
|
+
const abnormal = expectedRouteInvocation && (sawDirectToolBypass || !usedRoute);
|
|
95
|
+
const reason = !abnormal
|
|
96
|
+
? null
|
|
97
|
+
: sawDirectToolBypass
|
|
98
|
+
? "direct_tool_bypass_detected"
|
|
99
|
+
: "missing_tfx_route_evidence";
|
|
100
|
+
const slopDetected = detectSlop(stdoutText);
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
expectedRouteInvocation,
|
|
104
|
+
promptMentionsRoute,
|
|
105
|
+
sawRouteCommand,
|
|
106
|
+
sawRouteLog,
|
|
107
|
+
sawDirectToolBypass,
|
|
108
|
+
usedRoute,
|
|
109
|
+
abnormal,
|
|
110
|
+
reason,
|
|
111
|
+
slopDetected,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* role/mcp_profile별 tfx-route.sh 기본 timeout (초)
|
|
117
|
+
* analyze/review 프로필이나 설계·분석 역할은 더 긴 timeout을 부여한다.
|
|
118
|
+
* @param {string} role — 워커 역할
|
|
119
|
+
* @param {string} mcpProfile — MCP 프로필
|
|
120
|
+
* @returns {number} timeout(초)
|
|
121
|
+
*/
|
|
122
|
+
function getRouteTimeout(role, _mcpProfile) {
|
|
123
|
+
// tfx-route.sh route_agent()의 DEFAULT_TIMEOUT 기반, 최소 1080초(18분) 보장.
|
|
124
|
+
// Bash timeout = 이 값 + 60초 여유. 짧은 역할도 네트워크/스케줄 지연 대비.
|
|
125
|
+
const TIMEOUTS = {
|
|
126
|
+
'build-fixer': 1080, debugger: 1080, executor: 1080,
|
|
127
|
+
'deep-executor': 3600, architect: 3600, planner: 3600,
|
|
128
|
+
critic: 3600, analyst: 3600, scientist: 1800,
|
|
129
|
+
'scientist-deep': 3600, 'document-specialist': 1800,
|
|
130
|
+
'code-reviewer': 1800, 'security-reviewer': 1800,
|
|
131
|
+
'quality-reviewer': 1800, verifier: 1800,
|
|
132
|
+
designer: 1080, writer: 1080,
|
|
133
|
+
explore: 1080, 'test-engineer': 1080, 'qa-tester': 1080,
|
|
134
|
+
spark: 600,
|
|
135
|
+
};
|
|
136
|
+
return TIMEOUTS[role] || 1080;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* v3 슬림 래퍼 프롬프트 생성 (async 모드)
|
|
141
|
+
* --async로 즉시 시작 → --job-wait로 내부 폴링 → --job-result로 결과 수집.
|
|
142
|
+
* Claude Code Bash 도구 600초 제한을 우회하여 scientist(24분), scientist-deep(60분) 등
|
|
143
|
+
* 장시간 워커를 안정적으로 실행한다.
|
|
144
|
+
*
|
|
145
|
+
* @param {'codex'|'gemini'} cli — CLI 타입
|
|
146
|
+
* @param {object} opts
|
|
147
|
+
* @param {string} opts.subtask — 서브태스크 설명
|
|
148
|
+
* @param {string} [opts.role] — 역할 (executor, designer, reviewer 등)
|
|
149
|
+
* @param {string} [opts.teamName] — 팀 이름
|
|
150
|
+
* @param {string} [opts.taskId] — Hub task ID
|
|
151
|
+
* @param {string} [opts.agentName] — 워커 표시 이름
|
|
152
|
+
* @param {string} [opts.leadName] — 리드 수신자 이름
|
|
153
|
+
* @param {string} [opts.mcp_profile] — MCP 프로필
|
|
154
|
+
* @param {number} [opts.workerIndex] — 검색 힌트 회전에 사용할 워커 인덱스(1-based)
|
|
155
|
+
* @param {string} [opts.searchTool] — 전용 검색 도구 힌트(brave-search|tavily|exa)
|
|
156
|
+
* @param {number} [opts.bashTimeout] — (deprecated, async에서는 무시됨)
|
|
157
|
+
* @param {number} [opts.maxIterations=3] — 피드백 루프 최대 반복 횟수
|
|
158
|
+
* @returns {string} 슬림 래퍼 프롬프트
|
|
159
|
+
*/
|
|
160
|
+
export function buildSlimWrapperPrompt(cli, opts = {}) {
|
|
161
|
+
const {
|
|
162
|
+
subtask,
|
|
163
|
+
role = "executor",
|
|
164
|
+
teamName = "tfx-multi",
|
|
165
|
+
taskId = "",
|
|
166
|
+
agentName = "",
|
|
167
|
+
leadName = "team-lead",
|
|
168
|
+
mcp_profile = "auto",
|
|
169
|
+
workerIndex,
|
|
170
|
+
searchTool = "",
|
|
171
|
+
pipelinePhase = "",
|
|
172
|
+
maxIterations = 3,
|
|
173
|
+
} = opts;
|
|
174
|
+
|
|
175
|
+
const routeTimeoutSec = getRouteTimeout(role, mcp_profile);
|
|
176
|
+
const escaped = subtask.replace(/'/g, "'\\''");
|
|
177
|
+
const pipelineHint = pipelinePhase
|
|
178
|
+
? `\n파이프라인 단계: ${pipelinePhase}`
|
|
179
|
+
: '';
|
|
180
|
+
const routeEnvPrefix = buildRouteEnvPrefix(agentName, workerIndex, searchTool);
|
|
181
|
+
const scoutConstraint = (role === "scout" || role === "scientist")
|
|
182
|
+
? "\n이 워커는 scout(탐색 전용)이다. 코드를 수정하거나 파일을 생성하지 마라. 기존 코드를 읽고 분석하여 보고만 하라."
|
|
183
|
+
: "";
|
|
184
|
+
|
|
185
|
+
// Bash 도구 timeout (모두 600초 이내)
|
|
186
|
+
const launchTimeoutMs = 15000; // Step 1: fork + job_id 반환
|
|
187
|
+
const waitTimeoutMs = 570000; // Step 2: 내부 폴링 (540초 대기 + 여유)
|
|
188
|
+
const resultTimeoutMs = 30000; // Step 3: 결과 읽기
|
|
189
|
+
|
|
190
|
+
return `실행 프로토콜 (subagent_type="${SLIM_WRAPPER_SUBAGENT_TYPE}", async + feedback):
|
|
191
|
+
MAX_ITERATIONS = ${maxIterations}
|
|
192
|
+
ITERATION = 0${pipelineHint}
|
|
193
|
+
|
|
194
|
+
Step 0 — 시작 보고 (턴 경계 생성):
|
|
195
|
+
TaskUpdate(taskId: "${taskId}", status: "in_progress")
|
|
196
|
+
SendMessage(type: "message", recipient: "${leadName}", content: "작업 시작: ${agentName}", summary: "task ${taskId} started")
|
|
197
|
+
|
|
198
|
+
[HARD CONSTRAINT] 허용 도구: Bash, TaskUpdate, TaskGet, TaskList, SendMessage만 사용한다.
|
|
199
|
+
Read, Edit, Write, Grep, Glob, Agent, WebSearch, WebFetch 등 다른 모든 도구 사용을 금지한다.
|
|
200
|
+
코드를 직접 읽거나 수정하면 안 된다. 반드시 아래 Bash 명령(tfx-route.sh)을 통해 Codex/Gemini에 위임하라.
|
|
201
|
+
이 규칙을 위반하면 작업 실패로 간주한다.${scoutConstraint}
|
|
202
|
+
|
|
203
|
+
gemini/codex를 직접 호출하지 마라. 반드시 tfx-route.sh를 거쳐야 한다.
|
|
204
|
+
프롬프트를 파일로 저장하지 마라. tfx-route.sh가 인자로 받는다.
|
|
205
|
+
|
|
206
|
+
Step 1 — Async 시작 (즉시 리턴, <1초):
|
|
207
|
+
Bash(command: 'TFX_TEAM_NAME="${teamName}" TFX_TEAM_TASK_ID="${taskId}" TFX_TEAM_AGENT_NAME="${agentName}" TFX_TEAM_LEAD_NAME="${leadName}"${routeEnvPrefix} bash ${ROUTE_SCRIPT} --async "${role}" '"'"'${escaped}'"'"' ${mcp_profile} ${routeTimeoutSec}', timeout: ${launchTimeoutMs})
|
|
208
|
+
→ 출력 한 줄이 JOB_ID이다. 반드시 기억하라.
|
|
209
|
+
|
|
210
|
+
Step 2 — 완료 대기 (내부 폴링, 최대 540초):
|
|
211
|
+
Bash(command: 'bash ${ROUTE_SCRIPT} --job-wait JOB_ID 540', timeout: ${waitTimeoutMs})
|
|
212
|
+
→ 주기적 "waiting elapsed=Ns progress=NB" 출력 후 최종 상태:
|
|
213
|
+
"done" → Step 3으로
|
|
214
|
+
"timeout" 또는 "failed ..." → Step 4로 (실패 상태로)
|
|
215
|
+
"still_running ..." → Step 2 반복 (같은 명령 재실행)
|
|
216
|
+
|
|
217
|
+
Step 3 — 결과 수집:
|
|
218
|
+
Bash(command: 'bash ${ROUTE_SCRIPT} --job-result JOB_ID', timeout: ${resultTimeoutMs})
|
|
219
|
+
→ RESULT에 저장.
|
|
220
|
+
|
|
221
|
+
Step 4 — 결과 보고 (턴 경계 생성, TaskUpdate 하지 않음):
|
|
222
|
+
"done"이면:
|
|
223
|
+
SendMessage(type: "message", recipient: "${leadName}", content: "결과 (iteration ITERATION): ${agentName} 성공\\n{결과 요약}", summary: "task ${taskId} iteration ITERATION done")
|
|
224
|
+
"timeout" 또는 "failed"이면:
|
|
225
|
+
SendMessage(type: "message", recipient: "${leadName}", content: "결과 (iteration ITERATION): ${agentName} 실패\\n{에러 요약}", summary: "task ${taskId} iteration ITERATION failed")
|
|
226
|
+
TFX_NEEDS_FALLBACK 출력 감지 시:
|
|
227
|
+
→ Step 6으로 즉시 이동 (fallback은 재실행 불가)
|
|
228
|
+
|
|
229
|
+
Step 5 — 피드백 대기:
|
|
230
|
+
SendMessage 후 너는 IDLE 상태가 된다. 리드의 응답을 기다려라.
|
|
231
|
+
수신 메시지에 따라:
|
|
232
|
+
- "재실행:" 포함 → ITERATION++ → ITERATION < MAX_ITERATIONS이면 메시지의 지시를 반영하여 Step 1로. ITERATION >= MAX_ITERATIONS이면 Step 6으로 (반복 한도 초과)
|
|
233
|
+
- "승인" 또는 기타 → Step 6으로
|
|
234
|
+
- 메시지 없이 팀이 삭제되면 자동 종료 (처리 불필요)
|
|
235
|
+
|
|
236
|
+
Step 6 — 최종 종료 (반드시 실행):
|
|
237
|
+
TaskUpdate(taskId: "${taskId}", status: "completed", metadata: {result: "success"|"failed"|"fallback", iterations: ITERATION})
|
|
238
|
+
SendMessage(type: "message", recipient: "${leadName}", content: "최종 완료: ${agentName} (ITERATION회 실행)", summary: "task ${taskId} final")
|
|
239
|
+
→ 종료. 이후 추가 도구 호출 금지.`;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* scout 파견용 프롬프트 생성
|
|
244
|
+
* @param {object} opts
|
|
245
|
+
* @param {string} opts.question — 탐색 질문
|
|
246
|
+
* @param {string} [opts.scope] — 탐색 범위 힌트 (파일 패턴)
|
|
247
|
+
* @param {string} [opts.teamName] — 팀 이름
|
|
248
|
+
* @param {string} [opts.taskId] — 태스크 ID
|
|
249
|
+
* @param {string} [opts.agentName] — 에이전트 이름
|
|
250
|
+
* @param {string} [opts.leadName] — 리드 이름
|
|
251
|
+
* @returns {string} slim wrapper 프롬프트
|
|
252
|
+
*/
|
|
253
|
+
export function buildScoutDispatchPrompt(opts = {}) {
|
|
254
|
+
const { question, scope = "", teamName, taskId, agentName, leadName } = opts;
|
|
255
|
+
const subtask = scope
|
|
256
|
+
? `${question} 탐색 범위: ${scope}`
|
|
257
|
+
: question;
|
|
258
|
+
return buildSlimWrapperPrompt("codex", {
|
|
259
|
+
subtask,
|
|
260
|
+
role: "scientist",
|
|
261
|
+
teamName,
|
|
262
|
+
taskId,
|
|
263
|
+
agentName,
|
|
264
|
+
leadName,
|
|
265
|
+
mcp_profile: "analyze",
|
|
266
|
+
maxIterations: SCOUT_ROLE_CONFIG.maxIterations,
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* v3 하이브리드 래퍼 프롬프트 생성
|
|
272
|
+
* psmux pane 기반 비동기 실행 + polling 패턴.
|
|
273
|
+
* Agent가 idle 상태를 유지하여 인터럽트 수신이 가능하다.
|
|
274
|
+
*
|
|
275
|
+
* @param {'codex'|'gemini'} cli — CLI 타입
|
|
276
|
+
* @param {object} opts
|
|
277
|
+
* @param {string} opts.subtask — 서브태스크 설명
|
|
278
|
+
* @param {string} [opts.role] — 역할
|
|
279
|
+
* @param {string} [opts.teamName] — 팀 이름
|
|
280
|
+
* @param {string} [opts.taskId] — Hub task ID
|
|
281
|
+
* @param {string} [opts.agentName] — 워커 표시 이름
|
|
282
|
+
* @param {string} [opts.leadName] — 리드 수신자 이름
|
|
283
|
+
* @param {string} [opts.mcp_profile] — MCP 프로필
|
|
284
|
+
* @param {number} [opts.workerIndex] — 검색 힌트 회전에 사용할 워커 인덱스(1-based)
|
|
285
|
+
* @param {string} [opts.searchTool] — 전용 검색 도구 힌트(brave-search|tavily|exa)
|
|
286
|
+
* @param {string} [opts.sessionName] — psmux 세션 이름
|
|
287
|
+
* @param {string} [opts.pipelinePhase] — 파이프라인 단계
|
|
288
|
+
* @param {string} [opts.psmuxPath] — psmux.mjs 경로
|
|
289
|
+
* @returns {string} 하이브리드 래퍼 프롬프트
|
|
290
|
+
*/
|
|
291
|
+
export function buildHybridWrapperPrompt(cli, opts = {}) {
|
|
292
|
+
const {
|
|
293
|
+
subtask,
|
|
294
|
+
role = "executor",
|
|
295
|
+
teamName = "tfx-multi",
|
|
296
|
+
taskId = "",
|
|
297
|
+
agentName = "",
|
|
298
|
+
leadName = "team-lead",
|
|
299
|
+
mcp_profile = "auto",
|
|
300
|
+
workerIndex,
|
|
301
|
+
searchTool = "",
|
|
302
|
+
sessionName = teamName,
|
|
303
|
+
pipelinePhase = "",
|
|
304
|
+
psmuxPath = "hub/team/psmux.mjs",
|
|
305
|
+
} = opts;
|
|
306
|
+
|
|
307
|
+
const escaped = subtask.replace(/'/g, "'\\''");
|
|
308
|
+
const pipelineHint = pipelinePhase ? `\n파이프라인 단계: ${pipelinePhase}` : "";
|
|
309
|
+
const taskIdRef = taskId ? `taskId: "${taskId}"` : "";
|
|
310
|
+
const taskIdArg = taskIdRef ? `${taskIdRef}, ` : "";
|
|
311
|
+
const routeEnvPrefix = buildRouteEnvPrefix(agentName, workerIndex, searchTool);
|
|
312
|
+
|
|
313
|
+
const routeCmd = `TFX_TEAM_NAME="${teamName}" TFX_TEAM_TASK_ID="${taskId}" TFX_TEAM_AGENT_NAME="${agentName}" TFX_TEAM_LEAD_NAME="${leadName}"${routeEnvPrefix} bash ${ROUTE_SCRIPT} "${role}" '${escaped}' ${mcp_profile}`;
|
|
314
|
+
|
|
315
|
+
return `하이브리드 psmux 워커 프로토콜:
|
|
316
|
+
|
|
317
|
+
1. TaskUpdate(${taskIdArg}status: in_progress) + SendMessage(to: ${leadName}, "작업 시작: ${agentName}")
|
|
318
|
+
|
|
319
|
+
2. pane 생성 (비동기 실행):
|
|
320
|
+
Bash: node ${psmuxPath} spawn --session "${sessionName}" --name "${agentName}" --cmd "${routeCmd}"
|
|
321
|
+
|
|
322
|
+
3. 폴링 루프 (10초 간격, idle 유지 → 인터럽트 수신 가능):
|
|
323
|
+
Bash: node ${psmuxPath} status --session "${sessionName}" --name "${agentName}"
|
|
324
|
+
- status: "running" → 10초 대기 후 재확인
|
|
325
|
+
- status: "exited" → 5단계로
|
|
326
|
+
|
|
327
|
+
4. 인터럽트 수신 시:
|
|
328
|
+
Bash: node ${psmuxPath} kill --session "${sessionName}" --name "${agentName}"
|
|
329
|
+
→ SendMessage(to: ${leadName}, "인터럽트 수신, 방향 전환")
|
|
330
|
+
→ 새 지시에 따라 2단계부터 재실행
|
|
331
|
+
|
|
332
|
+
5. 완료 시:
|
|
333
|
+
Bash: node ${psmuxPath} output --session "${sessionName}" --name "${agentName}" --lines 100
|
|
334
|
+
→ 결과를 TaskUpdate + SendMessage로 보고
|
|
335
|
+
${pipelineHint}
|
|
336
|
+
[HARD CONSTRAINT] 너는 Bash, TaskUpdate, TaskGet, TaskList, SendMessage만 사용할 수 있다.
|
|
337
|
+
Read, Edit, Write, Grep, Glob, Agent, WebSearch, WebFetch 등 다른 모든 도구 사용을 금지한다.
|
|
338
|
+
코드를 직접 읽거나 수정하면 안 된다. 반드시 아래 Bash 명령(tfx-route.sh)을 통해 Codex/Gemini에 위임하라.
|
|
339
|
+
이 규칙을 위반하면 작업 실패로 간주한다.
|
|
340
|
+
|
|
341
|
+
gemini/codex를 직접 호출하지 마라. psmux spawn이 tfx-route.sh를 통해 실행한다.
|
|
342
|
+
프롬프트를 파일로 저장하지 마라. psmux spawn --cmd 인자로 전달된다.
|
|
343
|
+
|
|
344
|
+
성공 → TaskUpdate(${taskIdArg}status: completed, metadata: {result: "success"}) + SendMessage(to: ${leadName}).
|
|
345
|
+
실패 → TaskUpdate(${taskIdArg}status: completed, metadata: {result: "failed", error: "에러 요약"}) + SendMessage(to: ${leadName}).
|
|
346
|
+
|
|
347
|
+
중요: TaskUpdate의 status는 "completed"만 사용. "failed"는 API 미지원.
|
|
348
|
+
실패 여부는 metadata.result로 구분. pane 실패 시에도 반드시 TaskUpdate + SendMessage 후 종료.`;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/**
|
|
352
|
+
* tfx-route.sh가 남긴 로컬 결과 파일을 폴링해서 완료/대기 태스크를 분리한다.
|
|
353
|
+
* SendMessage 전달 지연이 있더라도 Phase 4에서 파일 기반으로 완료를 빠르게 감지하기 위한 보조 경로다.
|
|
354
|
+
*
|
|
355
|
+
* @param {string} teamName
|
|
356
|
+
* @param {string[]} expectedTaskIds
|
|
357
|
+
* @returns {Promise<{completed:Array<{taskId:string,result:string,summary:string}>, pending:string[]}>}
|
|
358
|
+
*/
|
|
359
|
+
export async function pollTeamResults(teamName, expectedTaskIds = []) {
|
|
360
|
+
const normalizedTaskIds = Array.from(
|
|
361
|
+
new Set(
|
|
362
|
+
(Array.isArray(expectedTaskIds) ? expectedTaskIds : [])
|
|
363
|
+
.map((taskId) => String(taskId || "").trim())
|
|
364
|
+
.filter(Boolean),
|
|
365
|
+
),
|
|
366
|
+
);
|
|
367
|
+
|
|
368
|
+
if (!normalizedTaskIds.length) {
|
|
369
|
+
return { completed: [], pending: [] };
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
const normalizedTeamName = String(teamName || "").trim();
|
|
373
|
+
if (!normalizedTeamName) {
|
|
374
|
+
return { completed: [], pending: normalizedTaskIds };
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const resultDir = path.join(os.homedir(), ".claude", "tfx-results", normalizedTeamName);
|
|
378
|
+
|
|
379
|
+
let entries;
|
|
380
|
+
try {
|
|
381
|
+
entries = await fs.readdir(resultDir, { withFileTypes: true });
|
|
382
|
+
} catch (error) {
|
|
383
|
+
if (error && error.code === "ENOENT") {
|
|
384
|
+
return { completed: [], pending: normalizedTaskIds };
|
|
385
|
+
}
|
|
386
|
+
throw error;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const availableFiles = new Set(
|
|
390
|
+
entries
|
|
391
|
+
.filter((entry) => entry.isFile())
|
|
392
|
+
.map((entry) => entry.name),
|
|
393
|
+
);
|
|
394
|
+
|
|
395
|
+
const completedCandidates = await Promise.all(
|
|
396
|
+
normalizedTaskIds.map(async (taskId) => {
|
|
397
|
+
const fileName = `${taskId}.json`;
|
|
398
|
+
if (!availableFiles.has(fileName)) return null;
|
|
399
|
+
|
|
400
|
+
try {
|
|
401
|
+
const raw = await fs.readFile(path.join(resultDir, fileName), "utf8");
|
|
402
|
+
const parsed = JSON.parse(raw);
|
|
403
|
+
return {
|
|
404
|
+
taskId,
|
|
405
|
+
result: typeof parsed?.result === "string" ? parsed.result : "failed",
|
|
406
|
+
summary: typeof parsed?.summary === "string" ? parsed.summary : "",
|
|
407
|
+
};
|
|
408
|
+
} catch (error) {
|
|
409
|
+
if (error && error.code === "ENOENT") return null;
|
|
410
|
+
return {
|
|
411
|
+
taskId,
|
|
412
|
+
result: "failed",
|
|
413
|
+
summary: "결과 파일 파싱 실패",
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
}),
|
|
417
|
+
);
|
|
418
|
+
|
|
419
|
+
const completed = completedCandidates.filter(Boolean);
|
|
420
|
+
const completedTaskIds = new Set(completed.map((item) => item.taskId));
|
|
421
|
+
const pending = normalizedTaskIds.filter((taskId) => !completedTaskIds.has(taskId));
|
|
422
|
+
|
|
423
|
+
return { completed, pending };
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* 폴링 결과를 진행률 한 줄 요약으로 바꾼다.
|
|
428
|
+
*
|
|
429
|
+
* @param {{completed?:Array<{taskId:string,result:string}>, pending?:string[]}} pollResult
|
|
430
|
+
* @returns {string}
|
|
431
|
+
*/
|
|
432
|
+
export function formatPollReport(pollResult = {}) {
|
|
433
|
+
const completed = Array.isArray(pollResult.completed) ? pollResult.completed : [];
|
|
434
|
+
const pending = Array.isArray(pollResult.pending) ? pollResult.pending : [];
|
|
435
|
+
const total = completed.length + pending.length;
|
|
436
|
+
|
|
437
|
+
if (total === 0) return "0/0 완료";
|
|
438
|
+
|
|
439
|
+
const detail = completed
|
|
440
|
+
.map(({ taskId, result }) => `${taskId} ${result || "unknown"}`)
|
|
441
|
+
.join(", ");
|
|
442
|
+
|
|
443
|
+
return detail
|
|
444
|
+
? `${completed.length}/${total} 완료 (${detail})`
|
|
445
|
+
: `${completed.length}/${total} 완료`;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// ── Anti-slop 필터링 ────────────────────────────────────────────
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* 문자열을 정규화: 소문자 변환 + 연속 공백을 단일 공백으로 + trim
|
|
452
|
+
* @param {string} s
|
|
453
|
+
* @returns {string}
|
|
454
|
+
*/
|
|
455
|
+
function normalizeText(s) {
|
|
456
|
+
return String(s || "").toLowerCase().replace(/\s+/g, " ").trim();
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* 두 문자열의 단어 집합 Jaccard 유사도를 계산한다.
|
|
461
|
+
* @param {string} a — 정규화된 문자열
|
|
462
|
+
* @param {string} b — 정규화된 문자열
|
|
463
|
+
* @returns {number} 0.0–1.0
|
|
464
|
+
*/
|
|
465
|
+
function jaccardSimilarity(a, b) {
|
|
466
|
+
const setA = new Set(a.split(" ").filter(Boolean));
|
|
467
|
+
const setB = new Set(b.split(" ").filter(Boolean));
|
|
468
|
+
if (setA.size === 0 && setB.size === 0) return 1.0;
|
|
469
|
+
if (setA.size === 0 || setB.size === 0) return 0.0;
|
|
470
|
+
let intersection = 0;
|
|
471
|
+
for (const w of setA) {
|
|
472
|
+
if (setB.has(w)) intersection++;
|
|
473
|
+
}
|
|
474
|
+
return intersection / (setA.size + setB.size - intersection);
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/**
|
|
478
|
+
* findings 배열에서 중복을 제거한다.
|
|
479
|
+
* 정규화 후 description의 Jaccard 유사도 >= 0.8이면 중복으로 판정.
|
|
480
|
+
* 동일 file+line인 경우도 중복 후보로 취급.
|
|
481
|
+
*
|
|
482
|
+
* @param {Array<{description:string, file?:string, line?:number, severity?:string}>} findings
|
|
483
|
+
* @returns {Array<{description:string, file?:string, line?:number, severity?:string, occurrences:number}>}
|
|
484
|
+
*/
|
|
485
|
+
export function deduplicateFindings(findings) {
|
|
486
|
+
if (!Array.isArray(findings) || findings.length === 0) return [];
|
|
487
|
+
|
|
488
|
+
const groups = []; // [{canonical, items:[]}]
|
|
489
|
+
|
|
490
|
+
for (const f of findings) {
|
|
491
|
+
const norm = normalizeText(f.description);
|
|
492
|
+
let merged = false;
|
|
493
|
+
for (const g of groups) {
|
|
494
|
+
if (jaccardSimilarity(norm, g.norm) >= 0.8) {
|
|
495
|
+
g.items.push(f);
|
|
496
|
+
merged = true;
|
|
497
|
+
break;
|
|
498
|
+
}
|
|
499
|
+
}
|
|
500
|
+
if (!merged) {
|
|
501
|
+
groups.push({ norm, canonical: f, items: [f] });
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
return groups.map((g) => ({
|
|
506
|
+
description: g.canonical.description,
|
|
507
|
+
...(g.canonical.file != null ? { file: g.canonical.file } : {}),
|
|
508
|
+
...(g.canonical.line != null ? { line: g.canonical.line } : {}),
|
|
509
|
+
...(g.canonical.severity != null ? { severity: g.canonical.severity } : {}),
|
|
510
|
+
occurrences: g.items.length,
|
|
511
|
+
}));
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/**
|
|
515
|
+
* scout 보고서 원문을 핵심 발견 사항만 추출하여 압축한다.
|
|
516
|
+
* 파일:라인 + 한줄 요약 형태로 변환하며 최대 ~500토큰(2000자) 이하로 제한.
|
|
517
|
+
*
|
|
518
|
+
* @param {string} rawReport — 자유형 텍스트
|
|
519
|
+
* @returns {{findings: Array<{file:string, line:string, summary:string}>, summary:string, tokenEstimate:number}}
|
|
520
|
+
*/
|
|
521
|
+
export function compressScoutReport(rawReport) {
|
|
522
|
+
const MAX_CHARS = 2000;
|
|
523
|
+
const text = String(rawReport || "");
|
|
524
|
+
|
|
525
|
+
// 파일:라인 패턴 추출 (path/to/file.ext:123 형태 + 뒤따르는 설명)
|
|
526
|
+
const fileLineRe = /([a-zA-Z0-9_./-]+\.[a-zA-Z]{1,10}):(\d+)\s*[:\-–—]?\s*(.+)/g;
|
|
527
|
+
const findings = [];
|
|
528
|
+
let match;
|
|
529
|
+
while ((match = fileLineRe.exec(text)) !== null) {
|
|
530
|
+
findings.push({
|
|
531
|
+
file: match[1],
|
|
532
|
+
line: match[2],
|
|
533
|
+
summary: match[3].trim().slice(0, 120),
|
|
534
|
+
});
|
|
535
|
+
}
|
|
536
|
+
|
|
537
|
+
// 문장 단위로 핵심 요약 구성
|
|
538
|
+
const sentences = text
|
|
539
|
+
.split(/[.\n]/)
|
|
540
|
+
.map((s) => s.trim())
|
|
541
|
+
.filter((s) => s.length > 10);
|
|
542
|
+
let summary = sentences.slice(0, 5).join(". ");
|
|
543
|
+
|
|
544
|
+
// 토큰 추정: ~4자 = 1토큰
|
|
545
|
+
const estimateTokens = (s) => Math.ceil(s.length / 4);
|
|
546
|
+
|
|
547
|
+
// findings를 먼저 계산하고, 남은 공간에 맞춰 summary를 자른다
|
|
548
|
+
const findingsJson = JSON.stringify(findings);
|
|
549
|
+
const findingsBudget = Math.min(findingsJson.length, Math.floor(MAX_CHARS * 0.3));
|
|
550
|
+
|
|
551
|
+
let trimmedFindings = findings;
|
|
552
|
+
if (findingsJson.length > findingsBudget && findings.length > 0) {
|
|
553
|
+
trimmedFindings = [];
|
|
554
|
+
let used = 2; // []
|
|
555
|
+
for (const f of findings) {
|
|
556
|
+
const entryLen = JSON.stringify(f).length + 1;
|
|
557
|
+
if (used + entryLen > findingsBudget) break;
|
|
558
|
+
trimmedFindings.push(f);
|
|
559
|
+
used += entryLen;
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
const summaryBudget = MAX_CHARS - JSON.stringify(trimmedFindings).length;
|
|
564
|
+
if (summary.length > summaryBudget) {
|
|
565
|
+
summary = summary.slice(0, Math.max(0, summaryBudget - 3)) + "...";
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
return {
|
|
569
|
+
findings: trimmedFindings,
|
|
570
|
+
summary,
|
|
571
|
+
tokenEstimate: estimateTokens(summary + JSON.stringify(trimmedFindings)),
|
|
572
|
+
};
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
/**
|
|
576
|
+
* 여러 scout 보고서의 발견 사항을 종합하여 가중 신뢰도를 계산한다.
|
|
577
|
+
* 동일 발견이 여러 scout에서 보고되면 신뢰도가 높다.
|
|
578
|
+
*
|
|
579
|
+
* @param {Array<{agentName:string, findings:Array<{description:string}>}>} scoutReports
|
|
580
|
+
* @returns {Array<{description:string, confidence:number, reporters:string[]}>}
|
|
581
|
+
*/
|
|
582
|
+
export function weightedConsensus(scoutReports) {
|
|
583
|
+
if (!Array.isArray(scoutReports) || scoutReports.length === 0) return [];
|
|
584
|
+
|
|
585
|
+
const totalScouts = scoutReports.length;
|
|
586
|
+
// {normDesc -> {description, reporters: Set}}
|
|
587
|
+
const consensusMap = new Map();
|
|
588
|
+
|
|
589
|
+
for (const report of scoutReports) {
|
|
590
|
+
const agent = String(report.agentName || "unknown");
|
|
591
|
+
const findings = Array.isArray(report.findings) ? report.findings : [];
|
|
592
|
+
for (const f of findings) {
|
|
593
|
+
const norm = normalizeText(f.description);
|
|
594
|
+
let matched = false;
|
|
595
|
+
for (const [key, entry] of consensusMap) {
|
|
596
|
+
if (jaccardSimilarity(norm, key) >= 0.8) {
|
|
597
|
+
entry.reporters.add(agent);
|
|
598
|
+
matched = true;
|
|
599
|
+
break;
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
if (!matched) {
|
|
603
|
+
consensusMap.set(norm, {
|
|
604
|
+
description: f.description,
|
|
605
|
+
reporters: new Set([agent]),
|
|
606
|
+
});
|
|
607
|
+
}
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
return Array.from(consensusMap.values()).map((entry) => ({
|
|
612
|
+
description: entry.description,
|
|
613
|
+
confidence: Math.round((entry.reporters.size / totalScouts) * 100) / 100,
|
|
614
|
+
reporters: Array.from(entry.reporters),
|
|
615
|
+
}));
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
// ── Slop detection helper ───────────────────────────────────────
|
|
619
|
+
|
|
620
|
+
const SLOP_REPEAT_THRESHOLD = 3;
|
|
621
|
+
|
|
622
|
+
/**
|
|
623
|
+
* 텍스트에서 동일 패턴이 SLOP_REPEAT_THRESHOLD회 이상 반복되는지 판정한다.
|
|
624
|
+
* 줄 단위로 정규화하여 비교.
|
|
625
|
+
* @param {string} text
|
|
626
|
+
* @returns {boolean}
|
|
627
|
+
*/
|
|
628
|
+
function detectSlop(text) {
|
|
629
|
+
const lines = String(text || "")
|
|
630
|
+
.split("\n")
|
|
631
|
+
.map(normalizeText)
|
|
632
|
+
.filter((l) => l.length > 15); // 너무 짧은 줄은 무시
|
|
633
|
+
const counts = new Map();
|
|
634
|
+
for (const line of lines) {
|
|
635
|
+
counts.set(line, (counts.get(line) || 0) + 1);
|
|
636
|
+
if (counts.get(line) >= SLOP_REPEAT_THRESHOLD) return true;
|
|
637
|
+
}
|
|
638
|
+
return false;
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
/**
|
|
642
|
+
* 팀 이름 생성 (타임스탬프 기반)
|
|
643
|
+
* @returns {string}
|
|
644
|
+
*/
|
|
645
|
+
export function generateTeamName() {
|
|
646
|
+
const ts = Date.now().toString(36).slice(-4);
|
|
647
|
+
const rand = Math.random().toString(36).slice(2, 6);
|
|
648
|
+
return `tfx-${ts}${rand}`;
|
|
649
|
+
}
|