triflux 3.3.0-dev.3 → 3.3.0-dev.6

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.
@@ -48,16 +48,76 @@ TFX_TEAM_NAME="${TFX_TEAM_NAME:-}"
48
48
  TFX_TEAM_TASK_ID="${TFX_TEAM_TASK_ID:-}"
49
49
  TFX_TEAM_AGENT_NAME="${TFX_TEAM_AGENT_NAME:-${AGENT_TYPE}-worker-$$}"
50
50
  TFX_TEAM_LEAD_NAME="${TFX_TEAM_LEAD_NAME:-team-lead}"
51
- TFX_HUB_URL="${TFX_HUB_URL:-http://127.0.0.1:27888}"
51
+ TFX_HUB_PIPE="${TFX_HUB_PIPE:-}"
52
+ TFX_HUB_URL="${TFX_HUB_URL:-http://127.0.0.1:27888}" # bridge.mjs HTTP fallback hint
52
53
 
53
54
  # fallback 시 원래 에이전트 정보 보존
54
55
  ORIGINAL_AGENT=""
55
56
  ORIGINAL_CLI_ARGS=""
56
57
 
58
+ # JSON 문자열 이스케이프:
59
+ # - "\", """ 필수 이스케이프
60
+ # - 제어문자 U+0000..U+001F 이스케이프
61
+ # - 비ASCII 문자는 \uXXXX(또는 surrogate pair)로 강제
62
+ json_escape() {
63
+ local s="${1:-}"
64
+
65
+ if command -v "$NODE_BIN" &>/dev/null; then
66
+ "$NODE_BIN" -e '
67
+ const input = process.argv[1] ?? "";
68
+ let out = "";
69
+ for (const ch of input) {
70
+ const cp = ch.codePointAt(0);
71
+ if (cp === 0x22) { out += "\\\""; continue; } // "
72
+ if (cp === 0x5c) { out += "\\\\"; continue; } // \
73
+ if (cp <= 0x1f) {
74
+ if (cp === 0x08) { out += "\\b"; continue; }
75
+ if (cp === 0x09) { out += "\\t"; continue; }
76
+ if (cp === 0x0a) { out += "\\n"; continue; }
77
+ if (cp === 0x0c) { out += "\\f"; continue; }
78
+ if (cp === 0x0d) { out += "\\r"; continue; }
79
+ out += `\\u${cp.toString(16).padStart(4, "0")}`;
80
+ continue;
81
+ }
82
+ if (cp >= 0x20 && cp <= 0x7e) {
83
+ out += ch;
84
+ continue;
85
+ }
86
+ if (cp <= 0xffff) {
87
+ out += `\\u${cp.toString(16).padStart(4, "0")}`;
88
+ continue;
89
+ }
90
+ const v = cp - 0x10000;
91
+ const hi = 0xd800 + (v >> 10);
92
+ const lo = 0xdc00 + (v & 0x3ff);
93
+ out += `\\u${hi.toString(16).padStart(4, "0")}\\u${lo.toString(16).padStart(4, "0")}`;
94
+ }
95
+ process.stdout.write(out);
96
+ ' -- "$s"
97
+ return
98
+ fi
99
+
100
+ echo "[tfx-route] ERROR: node 미설치로 안전한 JSON 이스케이프를 수행할 수 없습니다." >&2
101
+ return 1
102
+ }
103
+
57
104
  # ── Per-process 에이전트 등록 (원자적, 락 불필요) ──
58
105
  register_agent() {
59
106
  local agent_file="${TFX_TMP}/tfx-agent-$$.json"
60
- echo "{\"pid\":$$,\"cli\":\"$CLI_TYPE\",\"agent\":\"$AGENT_TYPE\",\"started\":$(date +%s)}" \
107
+ local safe_cli safe_agent started_at
108
+ safe_cli=$(json_escape "$CLI_TYPE" 2>/dev/null || true)
109
+ safe_agent=$(json_escape "$AGENT_TYPE" 2>/dev/null || true)
110
+ started_at=$(date +%s)
111
+
112
+ # fail-closed: 안전 인코딩 불가 시 agent 파일을 쓰지 않는다
113
+ if [[ -n "$CLI_TYPE" && -z "$safe_cli" ]]; then
114
+ return 0
115
+ fi
116
+ if [[ -n "$AGENT_TYPE" && -z "$safe_agent" ]]; then
117
+ return 0
118
+ fi
119
+
120
+ printf '{"pid":%s,"cli":"%s","agent":"%s","started":%s}\n' "$$" "$safe_cli" "$safe_agent" "$started_at" \
61
121
  > "$agent_file" 2>/dev/null || true
62
122
  }
63
123
 
@@ -65,45 +125,228 @@ deregister_agent() {
65
125
  rm -f "${TFX_TMP}/tfx-agent-$$.json" 2>/dev/null || true
66
126
  }
67
127
 
128
+ normalize_script_path() {
129
+ local path="${1:-}"
130
+ if [[ -z "$path" ]]; then
131
+ return 0
132
+ fi
133
+
134
+ if command -v cygpath &>/dev/null; then
135
+ case "$path" in
136
+ [A-Za-z]:\\*|[A-Za-z]:/*)
137
+ cygpath -u "$path"
138
+ return 0
139
+ ;;
140
+ esac
141
+ fi
142
+
143
+ printf '%s\n' "$path"
144
+ }
145
+
68
146
  # ── 팀 Hub Bridge 통신 ──
69
- # JSON 문자열 이스케이프 (큰따옴표, 백슬래시, 개행, 탭, CR)
70
- json_escape() {
71
- local s="${1:-}"
72
- # node로 완전한 JSON 이스케이프 (NUL, 멀티바이트 UTF-8, 제어문자 안전)
73
- if command -v node &>/dev/null; then
74
- node -e 'process.stdout.write(JSON.stringify(process.argv[1]).slice(1,-1))' -- "$s"
75
- return
147
+ resolve_bridge_script() {
148
+ if [[ -n "${TFX_BRIDGE_SCRIPT:-}" && -f "$TFX_BRIDGE_SCRIPT" ]]; then
149
+ printf '%s\n' "$TFX_BRIDGE_SCRIPT"
150
+ return 0
151
+ fi
152
+
153
+ local script_dir
154
+ script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
155
+ local candidates=(
156
+ "$script_dir/../hub/bridge.mjs"
157
+ "$script_dir/hub/bridge.mjs"
158
+ )
159
+
160
+ local candidate
161
+ for candidate in "${candidates[@]}"; do
162
+ if [[ -f "$candidate" ]]; then
163
+ printf '%s\n' "$candidate"
164
+ return 0
165
+ fi
166
+ done
167
+
168
+ return 1
169
+ }
170
+
171
+ bridge_cli() {
172
+ if ! command -v "$NODE_BIN" &>/dev/null; then
173
+ return 127
76
174
  fi
77
- # node 미설치 fallback: 기본 Bash 치환
78
- s="${s//\\/\\\\}"
79
- s="${s//\"/\\\"}"
80
- s="${s//$'\n'/\\n}"
81
- s="${s//$'\t'/\\t}"
82
- s="${s//$'\r'/\\r}"
83
- printf '%s' "$s"
175
+
176
+ local bridge_script
177
+ if ! bridge_script=$(resolve_bridge_script); then
178
+ return 127
179
+ fi
180
+
181
+ TFX_HUB_PIPE="$TFX_HUB_PIPE" TFX_HUB_URL="$TFX_HUB_URL" TFX_HUB_TOKEN="${TFX_HUB_TOKEN:-}" \
182
+ "$NODE_BIN" "$bridge_script" "$@" 2>/dev/null
183
+ }
184
+
185
+ bridge_json_get() {
186
+ local json="${1:-}"
187
+ local path="${2:-}"
188
+ [[ -z "$json" || -z "$path" ]] && return 1
189
+
190
+ "$NODE_BIN" -e '
191
+ const data = JSON.parse(process.argv[1] || "{}");
192
+ const keys = String(process.argv[2] || "").split(".").filter(Boolean);
193
+ let value = data;
194
+ for (const key of keys) value = value?.[key];
195
+ if (value === undefined || value === null) process.exit(1);
196
+ process.stdout.write(typeof value === "object" ? JSON.stringify(value) : String(value));
197
+ ' -- "$json" "$path" 2>/dev/null
198
+ }
199
+
200
+ bridge_json_stringify() {
201
+ local mode="${1:-}"
202
+ shift || true
203
+
204
+ case "$mode" in
205
+ metadata-patch)
206
+ "$NODE_BIN" -e '
207
+ process.stdout.write(JSON.stringify({
208
+ result: process.argv[1] || "",
209
+ summary: process.argv[2] || "",
210
+ }));
211
+ ' -- "${1:-}" "${2:-}"
212
+ ;;
213
+ task-result)
214
+ "$NODE_BIN" -e '
215
+ process.stdout.write(JSON.stringify({
216
+ task_id: process.argv[1] || "",
217
+ result: process.argv[2] || "",
218
+ }));
219
+ ' -- "${1:-}" "${2:-}"
220
+ ;;
221
+ *)
222
+ return 1
223
+ ;;
224
+ esac
225
+ }
226
+
227
+ team_send_message() {
228
+ local text="${1:-}"
229
+ local summary="${2:-}"
230
+ [[ -z "$TFX_TEAM_NAME" || -z "$text" ]] && return 0
231
+
232
+ if ! bridge_cli_with_restart "팀 메시지 전송" "Hub 재시작 후 팀 메시지 전송 성공." \
233
+ team-send-message \
234
+ --team "$TFX_TEAM_NAME" \
235
+ --from "$TFX_TEAM_AGENT_NAME" \
236
+ --to "$TFX_TEAM_LEAD_NAME" \
237
+ --text "$text" \
238
+ --summary "${summary:-status update}"; then
239
+ echo "[tfx-route] 경고: 팀 메시지 전송 실패 (team=$TFX_TEAM_NAME, to=$TFX_TEAM_LEAD_NAME)" >&2
240
+ return 0
241
+ fi
242
+
243
+ return 0
244
+ }
245
+
246
+ # ── Hub 자동 재시작 (슬립 복귀 등으로 Hub 종료 시) ──
247
+ try_restart_hub() {
248
+ local hub_server script_dir hub_port
249
+ script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
250
+ hub_server="$script_dir/../hub/server.mjs"
251
+
252
+ if [[ ! -f "$hub_server" ]]; then
253
+ echo "[tfx-route] Hub 서버 스크립트 미발견: $hub_server" >&2
254
+ return 1
255
+ fi
256
+
257
+ # TFX_HUB_URL에서 포트 추출 (기본 27888)
258
+ hub_port="${TFX_HUB_URL##*:}"
259
+ hub_port="${hub_port%%/*}"
260
+ [[ -z "$hub_port" || "$hub_port" == "$TFX_HUB_URL" ]] && hub_port=27888
261
+
262
+ echo "[tfx-route] Hub 미응답 — 자동 재시작 시도 (port=$hub_port)..." >&2
263
+ TFX_HUB_PORT="$hub_port" "$NODE_BIN" "$hub_server" &>/dev/null &
264
+ local hub_pid=$!
265
+
266
+ # 최대 4초 대기 (0.5초 간격)
267
+ local i
268
+ for i in 1 2 3 4 5 6 7 8; do
269
+ sleep 0.5
270
+ if curl -sf "${TFX_HUB_URL}/status" >/dev/null 2>&1; then
271
+ echo "[tfx-route] Hub 재시작 성공 (pid=$hub_pid)" >&2
272
+ return 0
273
+ fi
274
+ done
275
+
276
+ echo "[tfx-route] Hub 재시작 실패 — claim 없이 계속 실행" >&2
277
+ return 1
278
+ }
279
+
280
+ bridge_cli_with_restart() {
281
+ local action_label="${1:-bridge 호출}"
282
+ local success_message="${2:-}"
283
+ shift 2 || true
284
+
285
+ if bridge_cli "$@" >/dev/null 2>&1; then
286
+ return 0
287
+ fi
288
+
289
+ if ! try_restart_hub; then
290
+ return 1
291
+ fi
292
+
293
+ if bridge_cli "$@" >/dev/null 2>&1; then
294
+ [[ -n "$success_message" ]] && echo "[tfx-route] ${success_message}" >&2
295
+ return 0
296
+ fi
297
+
298
+ echo "[tfx-route] 경고: Hub 재시작 후 ${action_label} 재시도 실패." >&2
299
+ return 1
84
300
  }
85
301
 
86
302
  team_claim_task() {
87
303
  [[ -z "$TFX_TEAM_NAME" || -z "$TFX_TEAM_TASK_ID" ]] && return 0
88
- local http_code safe_team_name safe_task_id safe_agent_name
89
- safe_team_name=$(json_escape "$TFX_TEAM_NAME")
90
- safe_task_id=$(json_escape "$TFX_TEAM_TASK_ID")
91
- safe_agent_name=$(json_escape "$TFX_TEAM_AGENT_NAME")
92
-
93
- http_code=$(curl -sf -o /dev/null -w "%{http_code}" -X POST "${TFX_HUB_URL}/bridge/team/task-update" \
94
- -H "Content-Type: application/json" \
95
- -d "{\"team_name\":\"${safe_team_name}\",\"task_id\":\"${safe_task_id}\",\"claim\":true,\"owner\":\"${safe_agent_name}\",\"status\":\"in_progress\"}" \
96
- 2>/dev/null) || http_code="000"
97
-
98
- case "$http_code" in
99
- 200) ;; # 성공
100
- 409)
101
- echo "[tfx-route] CLAIM_CONFLICT: task ${TFX_TEAM_TASK_ID}가 이미 claim됨. 실행 중단." >&2
304
+ local response ok error_code error_message owner_before status_before
305
+ response=$(bridge_cli team-task-update \
306
+ --team "$TFX_TEAM_NAME" \
307
+ --task-id "$TFX_TEAM_TASK_ID" \
308
+ --claim \
309
+ --owner "$TFX_TEAM_AGENT_NAME" \
310
+ --status in_progress || true)
311
+
312
+ ok=$(bridge_json_get "$response" "ok" || true)
313
+ error_code=$(bridge_json_get "$response" "error.code" || true)
314
+ error_message=$(bridge_json_get "$response" "error.message" || true)
315
+ owner_before=$(bridge_json_get "$response" "error.details.task_before.owner" || true)
316
+ status_before=$(bridge_json_get "$response" "error.details.task_before.status" || true)
317
+
318
+ case "$ok:$error_code" in
319
+ true:*) ;;
320
+ false:CLAIM_CONFLICT)
321
+ if [[ "$owner_before" == "$TFX_TEAM_AGENT_NAME" && "$status_before" == "in_progress" ]]; then
322
+ echo "[tfx-route] 동일 owner(${TFX_TEAM_AGENT_NAME})가 이미 claim한 task ${TFX_TEAM_TASK_ID} — 계속 실행." >&2
323
+ return 0
324
+ fi
325
+ echo "[tfx-route] CLAIM_CONFLICT: task ${TFX_TEAM_TASK_ID}가 이미 claim됨(owner=${owner_before:-unknown}, status=${status_before:-unknown}). 실행 중단." >&2
326
+ team_send_message \
327
+ "task ${TFX_TEAM_TASK_ID} claim conflict: owner=${owner_before:-unknown}, status=${status_before:-unknown}" \
328
+ "task ${TFX_TEAM_TASK_ID} claim conflict"
102
329
  exit 0 ;;
103
- 000)
104
- echo "[tfx-route] 경고: Hub 연결 실패 (미실행?). claim 없이 계속 실행." >&2 ;;
330
+ :|false:)
331
+ # Hub 연결 실패 자동 재시작 시도 claim 재시도
332
+ if try_restart_hub; then
333
+ response=$(bridge_cli team-task-update \
334
+ --team "$TFX_TEAM_NAME" \
335
+ --task-id "$TFX_TEAM_TASK_ID" \
336
+ --claim \
337
+ --owner "$TFX_TEAM_AGENT_NAME" \
338
+ --status in_progress || true)
339
+ ok=$(bridge_json_get "$response" "ok" || true)
340
+ if [[ "$ok" == "true" ]]; then
341
+ echo "[tfx-route] Hub 재시작 후 claim 성공." >&2
342
+ else
343
+ echo "[tfx-route] 경고: Hub 재시작 후 claim 실패. claim 없이 계속 실행." >&2
344
+ fi
345
+ else
346
+ echo "[tfx-route] 경고: Hub 연결 실패 (미실행?). claim 없이 계속 실행." >&2
347
+ fi ;;
105
348
  *)
106
- echo "[tfx-route] 경고: Hub claim 응답 HTTP ${http_code}. claim 없이 계속 실행." >&2 ;;
349
+ echo "[tfx-route] 경고: Hub claim 실패 (${error_code:-unknown}${error_message:+: ${error_message}}). claim 없이 계속 실행." >&2 ;;
107
350
  esac
108
351
  }
109
352
 
@@ -112,32 +355,61 @@ team_complete_task() {
112
355
  local result_summary="${2:-작업 완료}"
113
356
  [[ -z "$TFX_TEAM_NAME" || -z "$TFX_TEAM_TASK_ID" ]] && return 0
114
357
 
115
- local safe_team_name safe_task_id safe_agent_name safe_result safe_summary safe_lead_name
116
- safe_team_name=$(json_escape "$TFX_TEAM_NAME")
117
- safe_task_id=$(json_escape "$TFX_TEAM_TASK_ID")
118
- safe_agent_name=$(json_escape "$TFX_TEAM_AGENT_NAME")
119
- safe_result=$(json_escape "$result")
120
- safe_summary=$(json_escape "$(echo "$result_summary" | head -c 4096)")
121
- safe_lead_name=$(json_escape "$TFX_TEAM_LEAD_NAME")
358
+ local summary_trimmed metadata_patch result_payload
359
+ summary_trimmed=$(echo "$result_summary" | head -c 4096)
360
+ metadata_patch=$(bridge_json_stringify metadata-patch "$result" "$summary_trimmed" 2>/dev/null || true)
361
+ result_payload=$(bridge_json_stringify task-result "$TFX_TEAM_TASK_ID" "$result" 2>/dev/null || true)
122
362
 
123
363
  # task 상태: 항상 "completed" (Claude Code API는 "failed" 미지원)
124
364
  # 실제 결과는 metadata.result로 전달
125
- curl -sf -X POST "${TFX_HUB_URL}/bridge/team/task-update" \
126
- -H "Content-Type: application/json" \
127
- -d "{\"team_name\":\"${safe_team_name}\",\"task_id\":\"${safe_task_id}\",\"status\":\"completed\",\"owner\":\"${safe_agent_name}\",\"metadata_patch\":{\"result\":\"${safe_result}\",\"summary\":\"${safe_summary}\"}}" \
128
- >/dev/null 2>&1 || true
365
+ if [[ -n "$metadata_patch" ]]; then
366
+ if ! bridge_cli_with_restart " task 완료 보고" "Hub 재시작 후 팀 task 완료 보고 성공." \
367
+ team-task-update \
368
+ --team "$TFX_TEAM_NAME" \
369
+ --task-id "$TFX_TEAM_TASK_ID" \
370
+ --status completed \
371
+ --owner "$TFX_TEAM_AGENT_NAME" \
372
+ --metadata-patch "$metadata_patch"; then
373
+ echo "[tfx-route] 경고: 팀 task 완료 보고 실패 (team=$TFX_TEAM_NAME, task=$TFX_TEAM_TASK_ID, result=$result)" >&2
374
+ fi
375
+ fi
129
376
 
130
377
  # 리드에게 메시지 전송
131
- curl -sf -X POST "${TFX_HUB_URL}/bridge/team/send-message" \
132
- -H "Content-Type: application/json" \
133
- -d "{\"team_name\":\"${safe_team_name}\",\"from\":\"${safe_agent_name}\",\"to\":\"${safe_lead_name}\",\"text\":\"${safe_summary}\",\"summary\":\"task ${safe_task_id} ${safe_result}\"}" \
134
- >/dev/null 2>&1 || true
378
+ team_send_message "$summary_trimmed" "task ${TFX_TEAM_TASK_ID} ${result}"
135
379
 
136
380
  # Hub result 발행 (poll_messages 채널 활성화)
137
- curl -sf -X POST "${TFX_HUB_URL}/bridge/result" \
138
- -H "Content-Type: application/json" \
139
- -d "{\"agent_id\":\"${safe_agent_name}\",\"topic\":\"task.result\",\"payload\":{\"task_id\":\"${safe_task_id}\",\"result\":\"${safe_result}\"},\"trace_id\":\"${safe_team_name}\"}" \
140
- >/dev/null 2>&1 || true
381
+ if [[ -n "$result_payload" ]]; then
382
+ if ! bridge_cli_with_restart "Hub result 발행" "Hub 재시작 후 Hub result 발행 성공." \
383
+ result \
384
+ --agent "$TFX_TEAM_AGENT_NAME" \
385
+ --topic task.result \
386
+ --payload "$result_payload" \
387
+ --trace "$TFX_TEAM_NAME"; then
388
+ echo "[tfx-route] 경고: Hub result 발행 실패 (agent=$TFX_TEAM_AGENT_NAME, task=$TFX_TEAM_TASK_ID)" >&2
389
+ fi
390
+ fi
391
+
392
+ # 로컬 결과 파일 백업 (세션 끊김 복구용)
393
+ # Claude 재로그인 시 Agent 래퍼가 죽어도 이 파일로 결과 수집 가능
394
+ local result_dir="${TFX_RESULT_DIR:-${HOME}/.claude/tfx-results/${TFX_TEAM_NAME}}"
395
+ if mkdir -p "$result_dir" 2>/dev/null; then
396
+ cat > "${result_dir}/${TFX_TEAM_TASK_ID}.json" 2>/dev/null <<RESULT_EOF
397
+ {"taskId":"${TFX_TEAM_TASK_ID}","agent":"${TFX_TEAM_AGENT_NAME}","team":"${TFX_TEAM_NAME}","result":"${result}","summary":$(printf '%s' "$summary_trimmed" | node -e "let d='';process.stdin.on('data',c=>d+=c);process.stdin.on('end',()=>console.log(JSON.stringify(d)))" 2>/dev/null || echo '""'),"timestamp":"$(date -u +%Y-%m-%dT%H:%M:%SZ)"}
398
+ RESULT_EOF
399
+ [[ $? -eq 0 ]] && echo "[tfx-route] 결과 백업: ${result_dir}/${TFX_TEAM_TASK_ID}.json" >&2
400
+ fi
401
+ }
402
+
403
+ capture_workspace_signature() {
404
+ if ! command -v git &>/dev/null; then
405
+ return 1
406
+ fi
407
+
408
+ if ! git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
409
+ return 1
410
+ fi
411
+
412
+ git status --short --untracked-files=all --ignore-submodules=all 2>/dev/null || return 1
141
413
  }
142
414
 
143
415
  # ── 라우팅 테이블 ──
@@ -253,6 +525,7 @@ route_agent() {
253
525
  # ── CLI 모드 오버라이드 (tfx-codex / tfx-gemini 스킬용) ──
254
526
  TFX_CLI_MODE="${TFX_CLI_MODE:-auto}"
255
527
  TFX_NO_CLAUDE_NATIVE="${TFX_NO_CLAUDE_NATIVE:-0}"
528
+ TFX_VERIFIER_OVERRIDE="${TFX_VERIFIER_OVERRIDE:-auto}"
256
529
  TFX_CODEX_TRANSPORT="${TFX_CODEX_TRANSPORT:-auto}"
257
530
  TFX_WORKER_INDEX="${TFX_WORKER_INDEX:-}"
258
531
  TFX_SEARCH_TOOL="${TFX_SEARCH_TOOL:-}"
@@ -270,6 +543,13 @@ case "$TFX_CODEX_TRANSPORT" in
270
543
  exit 1
271
544
  ;;
272
545
  esac
546
+ case "$TFX_VERIFIER_OVERRIDE" in
547
+ auto|claude) ;;
548
+ *)
549
+ echo "ERROR: TFX_VERIFIER_OVERRIDE 값은 auto 또는 claude여야 합니다. (현재: $TFX_VERIFIER_OVERRIDE)" >&2
550
+ exit 1
551
+ ;;
552
+ esac
273
553
  case "$TFX_WORKER_INDEX" in
274
554
  "") ;;
275
555
  *[!0-9]*|0)
@@ -391,8 +671,33 @@ apply_no_claude_native_mode() {
391
671
  echo "[tfx-route] TFX_NO_CLAUDE_NATIVE=1: $AGENT_TYPE -> codex($CLI_EFFORT) 리매핑" >&2
392
672
  }
393
673
 
674
+ apply_verifier_override() {
675
+ [[ "$AGENT_TYPE" != "verifier" ]] && return
676
+
677
+ case "$TFX_VERIFIER_OVERRIDE" in
678
+ auto|"")
679
+ return 0
680
+ ;;
681
+ claude)
682
+ ORIGINAL_AGENT="${ORIGINAL_AGENT:-$AGENT_TYPE}"
683
+ CLI_TYPE="claude-native"; CLI_CMD=""; CLI_ARGS=""
684
+ CLI_EFFORT="n/a"; DEFAULT_TIMEOUT=1200; RUN_MODE="fg"; OPUS_OVERSIGHT="false"
685
+ echo "[tfx-route] TFX_VERIFIER_OVERRIDE=claude: verifier -> claude-native" >&2
686
+ ;;
687
+ esac
688
+
689
+ return 0
690
+ }
691
+
394
692
  # ── MCP 인벤토리 캐시 ──
395
693
  MCP_CACHE="${HOME}/.claude/cache/mcp-inventory.json"
694
+ MCP_FILTER_SCRIPT=""
695
+ MCP_PROFILE_REQUESTED="auto"
696
+ MCP_RESOLVED_PROFILE="default"
697
+ MCP_HINT=""
698
+ GEMINI_ALLOWED_SERVERS=()
699
+ CODEX_CONFIG_FLAGS=()
700
+ CODEX_CONFIG_JSON=""
396
701
 
397
702
  get_cached_servers() {
398
703
  local cli_type="$1"
@@ -401,119 +706,71 @@ get_cached_servers() {
401
706
  fi
402
707
  }
403
708
 
404
- # ── MCP 프로필 → 프롬프트 힌트 (통합: 캐시 유무 단일 코드경로) ──
405
- get_mcp_hint() {
406
- local profile="$1"
407
- local agent="$2"
408
-
409
- # auto → 구체 프로필 해석
410
- if [[ "$profile" == "auto" ]]; then
411
- case "$agent" in
412
- executor|build-fixer|debugger|deep-executor) profile="implement" ;;
413
- architect|planner|critic|analyst) profile="analyze" ;;
414
- code-reviewer|security-reviewer|quality-reviewer) profile="review" ;;
415
- scientist|document-specialist) profile="analyze" ;;
416
- designer|writer) profile="docs" ;;
417
- *) profile="minimal" ;;
418
- esac
709
+ resolve_mcp_filter_script() {
710
+ if [[ -n "$MCP_FILTER_SCRIPT" && -f "$MCP_FILTER_SCRIPT" ]]; then
711
+ printf '%s\n' "$MCP_FILTER_SCRIPT"
712
+ return 0
419
713
  fi
420
714
 
421
- # 서버 목록: 캐시 있으면 실제, 없으면 전부 가용 가정 (기존 비캐시 동작과 동일)
422
- local servers
423
- servers=$(get_cached_servers "$CLI_TYPE")
424
- [[ -z "$servers" ]] && servers="context7,brave-search,exa,tavily,playwright,sequential-thinking"
715
+ local script_ref script_dir candidate
716
+ local -a candidates=()
425
717
 
426
- has_server() { echo ",$servers," | grep -q ",$1,"; }
427
-
428
- get_search_tool_order() {
429
- local available=()
430
- local ordered=()
431
- local tool
718
+ script_ref="$(normalize_script_path "${BASH_SOURCE[0]}")"
719
+ if [[ -n "$script_ref" ]]; then
720
+ script_dir="$(cd "$(dirname "$script_ref")" 2>/dev/null && pwd -P || true)"
721
+ [[ -n "$script_dir" ]] && candidates+=("$script_dir/lib/mcp-filter.mjs")
722
+ fi
432
723
 
433
- for tool in brave-search tavily exa; do
434
- has_server "$tool" && available+=("$tool")
435
- done
724
+ candidates+=(
725
+ "$PWD/scripts/lib/mcp-filter.mjs"
726
+ "$PWD/lib/mcp-filter.mjs"
727
+ )
436
728
 
437
- if [[ ${#available[@]} -eq 0 ]]; then
729
+ for candidate in "${candidates[@]}"; do
730
+ if [[ -f "$candidate" ]]; then
731
+ MCP_FILTER_SCRIPT="$candidate"
732
+ printf '%s\n' "$MCP_FILTER_SCRIPT"
438
733
  return 0
439
734
  fi
735
+ done
440
736
 
441
- if [[ -n "$TFX_SEARCH_TOOL" ]]; then
442
- for tool in "${available[@]}"; do
443
- [[ "$tool" == "$TFX_SEARCH_TOOL" ]] && ordered+=("$tool")
444
- done
445
- for tool in "${available[@]}"; do
446
- [[ "$tool" != "$TFX_SEARCH_TOOL" ]] && ordered+=("$tool")
447
- done
448
- elif [[ -n "$TFX_WORKER_INDEX" && ${#available[@]} -gt 1 ]]; then
449
- local offset=$(( (TFX_WORKER_INDEX - 1) % ${#available[@]} ))
450
- local i idx
451
- for ((i=0; i<${#available[@]}; i++)); do
452
- idx=$(( (offset + i) % ${#available[@]} ))
453
- ordered+=("${available[$idx]}")
454
- done
455
- else
456
- ordered=("${available[@]}")
457
- fi
458
-
459
- printf '%s\n' "${ordered[*]}"
460
- }
737
+ return 1
738
+ }
461
739
 
462
- local ordered_tools=()
463
- read -r -a ordered_tools <<< "$(get_search_tool_order)"
464
- local ordered_tools_csv=""
465
- if [[ ${#ordered_tools[@]} -gt 0 ]]; then
466
- ordered_tools_csv=$(printf '%s, ' "${ordered_tools[@]}")
467
- ordered_tools_csv="${ordered_tools_csv%, }"
740
+ resolve_mcp_policy() {
741
+ local filter_script available_servers
742
+ if ! filter_script=$(resolve_mcp_filter_script); then
743
+ echo "[tfx-route] 경고: mcp-filter.mjs를 찾지 못해 기본 MCP 정책을 사용합니다." >&2
744
+ MCP_PROFILE_REQUESTED="$MCP_PROFILE"
745
+ MCP_RESOLVED_PROFILE="$MCP_PROFILE"
746
+ MCP_HINT=""
747
+ GEMINI_ALLOWED_SERVERS=()
748
+ CODEX_CONFIG_FLAGS=()
749
+ CODEX_CONFIG_JSON=""
750
+ return 0
468
751
  fi
469
752
 
470
- local hint=""
471
- case "$profile" in
472
- implement)
473
- has_server "context7" && hint+="context7으로 라이브러리 문서를 조회하세요. "
474
- if [[ ${#ordered_tools[@]} -gt 0 ]]; then
475
- hint+="웹 검색은 ${ordered_tools[0]}를 사용하세요. "
476
- fi
477
- hint+="검색 도구 실패 시 402, 429, 432, 433, quota 에러에서 재시도하지 말고 다음 도구로 전환하세요."
478
- ;;
479
- analyze)
480
- has_server "context7" && hint+="context7으로 관련 문서를 조회하세요. "
481
- [[ -n "$ordered_tools_csv" ]] && hint+="웹 검색 우선순위: ${ordered_tools_csv}. 402, 429, 432, 433, quota 에러 시 즉시 다음 도구로 전환. "
482
- has_server "playwright" && hint+="모든 검색 실패 시 playwright로 직접 방문 (최대 3 URL). "
483
- hint+="검색 깊이를 제한하고 결과를 빠르게 요약하세요."
484
- ;;
485
- review)
486
- has_server "sequential-thinking" && hint="sequential-thinking으로 체계적으로 분석하세요."
487
- ;;
488
- docs)
489
- has_server "context7" && hint+="context7으로 공식 문서를 참조하세요. "
490
- if [[ ${#ordered_tools[@]} -gt 0 ]]; then
491
- hint+="추가 검색은 ${ordered_tools[0]}를 사용하세요. "
492
- fi
493
- hint+="검색 결과의 출처 URL을 함께 제시하세요."
494
- ;;
495
- minimal|none) ;;
496
- esac
497
- echo "$hint"
498
- }
753
+ available_servers=$(get_cached_servers "$CLI_TYPE")
754
+ [[ -z "$available_servers" ]] && available_servers="context7,brave-search,exa,tavily,playwright,sequential-thinking"
499
755
 
500
- # ── Gemini MCP 서버 선택적 로드 ──
501
- get_gemini_mcp_servers() {
502
- local profile="$1"
503
- case "$profile" in
504
- implement) echo "context7 brave-search" ;;
505
- analyze) echo "context7 brave-search exa tavily" ;;
506
- review) echo "sequential-thinking" ;;
507
- docs) echo "context7 brave-search" ;;
508
- *) echo "" ;;
509
- esac
510
- }
756
+ local -a cmd=(
757
+ "$NODE_BIN" "$filter_script" shell
758
+ "--agent" "$AGENT_TYPE"
759
+ "--profile" "$MCP_PROFILE"
760
+ "--available" "$available_servers"
761
+ "--inventory-file" "$MCP_CACHE"
762
+ "--task-text" "$PROMPT"
763
+ )
764
+ [[ -n "$TFX_SEARCH_TOOL" ]] && cmd+=("--search-tool" "$TFX_SEARCH_TOOL")
765
+ [[ -n "$TFX_WORKER_INDEX" ]] && cmd+=("--worker-index" "$TFX_WORKER_INDEX")
511
766
 
512
- get_gemini_mcp_filter() {
513
- local servers
514
- servers=$(get_gemini_mcp_servers "$1")
515
- [[ -z "$servers" ]] && return 0
516
- echo "--allowed-mcp-server-names ${servers// /,}"
767
+ local shell_exports
768
+ if ! shell_exports="$("${cmd[@]}")"; then
769
+ echo "[tfx-route] ERROR: MCP 정책 계산 실패" >&2
770
+ return 1
771
+ fi
772
+
773
+ eval "$shell_exports"
517
774
  }
518
775
 
519
776
  get_claude_model() {
@@ -544,8 +801,9 @@ resolve_worker_runner_script() {
544
801
  return 0
545
802
  fi
546
803
 
547
- local script_dir
548
- script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
804
+ local script_ref script_dir
805
+ script_ref="$(normalize_script_path "${BASH_SOURCE[0]}")"
806
+ script_dir="$(cd "$(dirname "$script_ref")" && pwd -P)"
549
807
  local candidate="$script_dir/tfx-route-worker.mjs"
550
808
  [[ -f "$candidate" ]] || return 1
551
809
  printf '%s\n' "$candidate"
@@ -587,19 +845,32 @@ run_stream_worker() {
587
845
  run_legacy_gemini() {
588
846
  local prompt="$1"
589
847
  local use_tee_flag="$2"
590
- local gemini_mcp_filter
591
- gemini_mcp_filter=$(get_gemini_mcp_filter "$MCP_PROFILE")
592
- local gemini_args="$CLI_ARGS"
593
-
594
- if [[ -n "$gemini_mcp_filter" ]]; then
595
- gemini_args="${CLI_ARGS/--prompt/$gemini_mcp_filter --prompt}"
596
- echo "[tfx-route] Gemini MCP 필터: $gemini_mcp_filter" >&2
848
+ local -a gemini_args=()
849
+ read -r -a gemini_args <<< "$CLI_ARGS"
850
+
851
+ if [[ ${#GEMINI_ALLOWED_SERVERS[@]} -gt 0 ]]; then
852
+ local gemini_mcp_filter prompt_index=-1
853
+ gemini_mcp_filter=$(IFS=,; echo "${GEMINI_ALLOWED_SERVERS[*]}")
854
+ for i in "${!gemini_args[@]}"; do
855
+ if [[ "${gemini_args[$i]}" == "--prompt" ]]; then
856
+ prompt_index="$i"
857
+ break
858
+ fi
859
+ done
860
+ if [[ "$prompt_index" -ge 0 ]]; then
861
+ gemini_args=(
862
+ "${gemini_args[@]:0:$prompt_index}"
863
+ "--allowed-mcp-server-names" "$gemini_mcp_filter"
864
+ "${gemini_args[@]:$prompt_index}"
865
+ )
866
+ echo "[tfx-route] Gemini MCP 필터: $gemini_mcp_filter" >&2
867
+ fi
597
868
  fi
598
869
 
599
870
  if [[ "$use_tee_flag" == "true" ]]; then
600
- timeout "$TIMEOUT_SEC" $CLI_CMD $gemini_args "$prompt" 2>"$STDERR_LOG" | tee "$STDOUT_LOG" &
871
+ timeout "$TIMEOUT_SEC" "$CLI_CMD" "${gemini_args[@]}" "$prompt" 2>"$STDERR_LOG" | tee "$STDOUT_LOG" &
601
872
  else
602
- timeout "$TIMEOUT_SEC" $CLI_CMD $gemini_args "$prompt" >"$STDOUT_LOG" 2>"$STDERR_LOG" &
873
+ timeout "$TIMEOUT_SEC" "$CLI_CMD" "${gemini_args[@]}" "$prompt" >"$STDOUT_LOG" 2>"$STDERR_LOG" &
603
874
  fi
604
875
  local pid=$!
605
876
 
@@ -622,9 +893,9 @@ run_legacy_gemini() {
622
893
  wait "$pid" 2>/dev/null
623
894
  echo "[tfx-route] Gemini crash 감지, 재시도 중..." >&2
624
895
  if [[ "$use_tee_flag" == "true" ]]; then
625
- timeout "$TIMEOUT_SEC" $CLI_CMD $gemini_args "$prompt" 2>"$STDERR_LOG" | tee "$STDOUT_LOG" &
896
+ timeout "$TIMEOUT_SEC" "$CLI_CMD" "${gemini_args[@]}" "$prompt" 2>"$STDERR_LOG" | tee "$STDOUT_LOG" &
626
897
  else
627
- timeout "$TIMEOUT_SEC" $CLI_CMD $gemini_args "$prompt" >"$STDOUT_LOG" 2>"$STDERR_LOG" &
898
+ timeout "$TIMEOUT_SEC" "$CLI_CMD" "${gemini_args[@]}" "$prompt" >"$STDOUT_LOG" 2>"$STDERR_LOG" &
628
899
  fi
629
900
  pid=$!
630
901
  fi
@@ -639,8 +910,9 @@ resolve_codex_mcp_script() {
639
910
  return 0
640
911
  fi
641
912
 
642
- local script_dir
643
- script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
913
+ local script_ref script_dir
914
+ script_ref="$(normalize_script_path "${BASH_SOURCE[0]}")"
915
+ script_dir="$(cd "$(dirname "$script_ref")" && pwd -P)"
644
916
  local candidates=(
645
917
  "$script_dir/hub/workers/codex-mcp.mjs"
646
918
  "$script_dir/../hub/workers/codex-mcp.mjs"
@@ -661,11 +933,16 @@ run_codex_exec() {
661
933
  local prompt="$1"
662
934
  local use_tee_flag="$2"
663
935
  local exit_code_local=0
936
+ local -a codex_args=()
937
+ read -r -a codex_args <<< "$CLI_ARGS"
938
+ if [[ ${#CODEX_CONFIG_FLAGS[@]} -gt 0 ]]; then
939
+ codex_args+=("${CODEX_CONFIG_FLAGS[@]}")
940
+ fi
664
941
 
665
942
  if [[ "$use_tee_flag" == "true" ]]; then
666
- timeout "$TIMEOUT_SEC" $CLI_CMD $CLI_ARGS "$prompt" 2>"$STDERR_LOG" | tee "$STDOUT_LOG" || exit_code_local=$?
943
+ timeout "$TIMEOUT_SEC" "$CLI_CMD" "${codex_args[@]}" "$prompt" 2>"$STDERR_LOG" | tee "$STDOUT_LOG" || exit_code_local=$?
667
944
  else
668
- timeout "$TIMEOUT_SEC" $CLI_CMD $CLI_ARGS "$prompt" >"$STDOUT_LOG" 2>"$STDERR_LOG" || exit_code_local=$?
945
+ timeout "$TIMEOUT_SEC" "$CLI_CMD" "${codex_args[@]}" "$prompt" >"$STDOUT_LOG" 2>"$STDERR_LOG" || exit_code_local=$?
669
946
  fi
670
947
 
671
948
  if [[ ! -s "$STDOUT_LOG" && -s "$STDERR_LOG" ]]; then
@@ -723,6 +1000,10 @@ run_codex_mcp() {
723
1000
  "--codex-command" "$CODEX_BIN"
724
1001
  )
725
1002
 
1003
+ if [[ -n "$CODEX_CONFIG_JSON" && "$CODEX_CONFIG_JSON" != "{}" ]]; then
1004
+ mcp_args+=("--config-json" "$CODEX_CONFIG_JSON")
1005
+ fi
1006
+
726
1007
  case "$AGENT_TYPE" in
727
1008
  code-reviewer)
728
1009
  mcp_args+=(
@@ -767,6 +1048,7 @@ main() {
767
1048
  route_agent "$AGENT_TYPE"
768
1049
  apply_cli_mode
769
1050
  apply_no_claude_native_mode
1051
+ apply_verifier_override
770
1052
 
771
1053
  # CLI 경로 해석
772
1054
  case "$CLI_CMD" in
@@ -811,6 +1093,8 @@ ${ctx_content}
811
1093
  </prior_context>"
812
1094
  fi
813
1095
 
1096
+ resolve_mcp_policy
1097
+
814
1098
  # Claude native는 팀 비-TTY 환경에서 subprocess wrapper를 우선 시도
815
1099
  if [[ "$CLI_TYPE" == "claude-native" && -n "$TFX_TEAM_NAME" ]]; then
816
1100
  if { [[ ! -t 0 ]] || [[ ! -t 1 ]]; } && command -v "$CLAUDE_BIN" &>/dev/null && resolve_worker_runner_script >/dev/null 2>&1; then
@@ -822,22 +1106,37 @@ ${ctx_content}
822
1106
  fi
823
1107
  fi
824
1108
 
825
- # Claude 네이티브 에이전트는 이 스크립트로 처리 불가 → 메타데이터만 출력
1109
+ # Claude 네이티브 에이전트는 이 스크립트로 처리 불가
826
1110
  if [[ "$CLI_TYPE" == "claude-native" ]]; then
1111
+ if [[ -n "$TFX_TEAM_NAME" ]]; then
1112
+ # 팀 모드: Hub에 fallback 필요 시그널 전송 후 구조화된 출력
1113
+ echo "[tfx-route] claude-native 역할($AGENT_TYPE)은 tfx-route.sh로 실행 불가 — Claude Agent fallback 필요" >&2
1114
+ team_complete_task "fallback" "claude-native 역할 실행 불가: ${AGENT_TYPE}. Claude Task(sonnet) 에이전트로 위임하세요."
1115
+ cat <<FALLBACK_EOF
1116
+ === TFX_NEEDS_FALLBACK ===
1117
+ agent_type: ${AGENT_TYPE}
1118
+ reason: claude-native roles require Claude Agent tools (Read/Edit/Grep). tfx-route.sh cannot provide these.
1119
+ action: Lead should spawn Agent(subagent_type="${AGENT_TYPE}") for this task.
1120
+ task_id: ${TFX_TEAM_TASK_ID:-none}
1121
+ FALLBACK_EOF
1122
+ exit 0
1123
+ fi
827
1124
  emit_claude_native_metadata
828
1125
  exit 0
829
1126
  fi
830
1127
 
831
- # MCP 힌트 주입
832
- local mcp_hint
833
- mcp_hint=$(get_mcp_hint "$MCP_PROFILE" "$AGENT_TYPE")
834
1128
  local FULL_PROMPT="$PROMPT"
835
- [[ -n "$mcp_hint" ]] && FULL_PROMPT="${PROMPT}. ${mcp_hint}"
1129
+ [[ -n "$MCP_HINT" ]] && FULL_PROMPT="${PROMPT}. ${MCP_HINT}"
836
1130
  local codex_transport_effective="n/a"
837
1131
 
838
1132
  # 메타정보 (stderr)
839
1133
  echo "[tfx-route] v${VERSION} type=$CLI_TYPE agent=$AGENT_TYPE effort=$CLI_EFFORT mode=$RUN_MODE timeout=${TIMEOUT_SEC}s" >&2
840
- echo "[tfx-route] opus_oversight=$OPUS_OVERSIGHT mcp_profile=$MCP_PROFILE" >&2
1134
+ echo "[tfx-route] opus_oversight=$OPUS_OVERSIGHT mcp_profile=$MCP_PROFILE resolved_profile=$MCP_RESOLVED_PROFILE verifier_override=$TFX_VERIFIER_OVERRIDE" >&2
1135
+ if [[ ${#GEMINI_ALLOWED_SERVERS[@]} -gt 0 ]]; then
1136
+ echo "[tfx-route] allowed_mcp_servers=$(IFS=,; echo "${GEMINI_ALLOWED_SERVERS[*]}")" >&2
1137
+ else
1138
+ echo "[tfx-route] allowed_mcp_servers=none" >&2
1139
+ fi
841
1140
  if [[ -n "$TFX_WORKER_INDEX" || -n "$TFX_SEARCH_TOOL" ]]; then
842
1141
  echo "[tfx-route] worker_index=${TFX_WORKER_INDEX:-auto} search_tool=${TFX_SEARCH_TOOL:-auto}" >&2
843
1142
  fi
@@ -851,11 +1150,18 @@ ${ctx_content}
851
1150
 
852
1151
  # 팀 모드: task claim
853
1152
  team_claim_task
1153
+ team_send_message "작업 시작: ${TFX_TEAM_AGENT_NAME}" "task ${TFX_TEAM_TASK_ID} started"
854
1154
 
855
1155
  # CLI 실행 (stderr 분리 + 타임아웃 + 소요시간 측정)
856
1156
  local exit_code=0
857
1157
  local start_time
858
1158
  start_time=$(date +%s)
1159
+ local workspace_signature_before=""
1160
+ local workspace_signature_after=""
1161
+ local workspace_probe_supported=false
1162
+ if workspace_signature_before=$(capture_workspace_signature); then
1163
+ workspace_probe_supported=true
1164
+ fi
859
1165
 
860
1166
  # tee 활성화 조건: 팀 모드 + 실제 터미널(TTY/tmux)
861
1167
  # Agent 래퍼 안에서는 가상 stdout 캡처로 tee 출력이 사용자에게 안 보임 → 파일 전용
@@ -899,8 +1205,6 @@ ${ctx_content}
899
1205
  }
900
1206
  }
901
1207
  }' <<< "$CLI_ARGS")
902
- local gemini_servers
903
- gemini_servers=$(get_gemini_mcp_servers "$MCP_PROFILE")
904
1208
  local -a gemini_worker_args=(
905
1209
  "--command" "$CLI_CMD"
906
1210
  "--command-args-json" "$GEMINI_BIN_ARGS_JSON"
@@ -908,10 +1212,10 @@ ${ctx_content}
908
1212
  "--approval-mode" "yolo"
909
1213
  )
910
1214
 
911
- if [[ -n "$gemini_servers" ]]; then
912
- echo "[tfx-route] Gemini MCP 서버: ${gemini_servers}" >&2
1215
+ if [[ ${#GEMINI_ALLOWED_SERVERS[@]} -gt 0 ]]; then
1216
+ echo "[tfx-route] Gemini MCP 서버: $(IFS=' '; echo "${GEMINI_ALLOWED_SERVERS[*]}")" >&2
913
1217
  local server_name
914
- for server_name in $gemini_servers; do
1218
+ for server_name in "${GEMINI_ALLOWED_SERVERS[@]}"; do
915
1219
  gemini_worker_args+=("--allowed-mcp-server-name" "$server_name")
916
1220
  done
917
1221
  fi
@@ -952,6 +1256,24 @@ EOF
952
1256
  end_time=$(date +%s)
953
1257
  local elapsed=$((end_time - start_time))
954
1258
 
1259
+ if [[ "$exit_code" -eq 0 ]]; then
1260
+ local workspace_changed="unknown"
1261
+ if [[ "$workspace_probe_supported" == "true" ]]; then
1262
+ if workspace_signature_after=$(capture_workspace_signature); then
1263
+ if [[ "$workspace_signature_before" != "$workspace_signature_after" ]]; then
1264
+ workspace_changed="yes"
1265
+ else
1266
+ workspace_changed="no"
1267
+ fi
1268
+ fi
1269
+ fi
1270
+
1271
+ if [[ ! -s "$STDOUT_LOG" && "$workspace_changed" == "no" ]]; then
1272
+ printf '%s\n' "[tfx-route] exit 0 이지만 stdout 비어있고 워크스페이스 변화가 없습니다. no-op 성공을 실패로 승격합니다." >> "$STDERR_LOG"
1273
+ exit_code=68
1274
+ fi
1275
+ fi
1276
+
955
1277
  # 팀 모드: task complete + 리드 보고
956
1278
  if [[ -n "$TFX_TEAM_NAME" ]]; then
957
1279
  if [[ "$exit_code" -eq 0 ]]; then
@@ -997,6 +1319,8 @@ EOF
997
1319
  echo "=== OUTPUT ==="
998
1320
  cat "$STDOUT_LOG" 2>/dev/null | head -c "$MAX_STDOUT_BYTES"
999
1321
  fi
1322
+
1323
+ return "$exit_code"
1000
1324
  }
1001
1325
 
1002
1326
  main