triflux 10.50.2 → 10.52.0

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.
Files changed (263) hide show
  1. package/CLAUDE.md +57 -41
  2. package/README.ko.md +41 -45
  3. package/README.md +41 -45
  4. package/adapters/codex/skills/tfx-live/SKILL.md +25 -11
  5. package/bin/tfx-live.mjs +341 -104
  6. package/bin/triflux.mjs +147 -2624
  7. package/config/mcp-registry.json +3 -12
  8. package/cto/brief.mjs +0 -20
  9. package/cto/collect.mjs +1 -200
  10. package/cto/current.schema.json +2 -23
  11. package/cto/hygiene.mjs +2 -61
  12. package/cto/lake-root.mjs +0 -56
  13. package/cto/status.mjs +5 -187
  14. package/hooks/agy-session-hook.mjs +11 -56
  15. package/hooks/codex-session-hook.mjs +11 -101
  16. package/hub/bridge.mjs +65 -1250
  17. package/hub/cli-adapter-base.mjs +12 -90
  18. package/hub/codex-adapter.mjs +0 -1
  19. package/hub/codex-preflight.mjs +5 -28
  20. package/hub/lib/process-utils.mjs +1 -156
  21. package/hub/lib/spawn-trace.mjs +3 -2
  22. package/hub/lib/timeout-defaults.mjs +0 -1
  23. package/hub/team/agent-map.json +0 -1
  24. package/hub/team/agents-row-attach.py +120 -0
  25. package/hub/team/agents-row.mjs +200 -0
  26. package/hub/team/ansi.mjs +1 -35
  27. package/hub/team/backend.mjs +9 -145
  28. package/hub/team/claude-daemon-control.mjs +1 -2
  29. package/hub/team/claude-session-projection.mjs +5 -73
  30. package/hub/team/cli/commands/attach.mjs +1 -17
  31. package/hub/team/cli/commands/control.mjs +12 -38
  32. package/hub/team/cli/commands/debug.mjs +2 -39
  33. package/hub/team/cli/commands/focus.mjs +5 -31
  34. package/hub/team/cli/commands/interrupt.mjs +6 -25
  35. package/hub/team/cli/commands/kill.mjs +1 -30
  36. package/hub/team/cli/commands/list.mjs +0 -16
  37. package/hub/team/cli/commands/send.mjs +6 -26
  38. package/hub/team/cli/commands/start/index.mjs +42 -133
  39. package/hub/team/cli/commands/start/parse-args.mjs +15 -40
  40. package/hub/team/cli/commands/start/start-headless.mjs +138 -105
  41. package/hub/team/cli/commands/start/start-mux.mjs +2 -7
  42. package/hub/team/cli/commands/status.mjs +2 -57
  43. package/hub/team/cli/commands/stop.mjs +14 -19
  44. package/hub/team/cli/help.mjs +4 -5
  45. package/hub/team/cli/manifest.mjs +5 -1
  46. package/hub/team/cli/render.mjs +0 -12
  47. package/hub/team/cli/services/runtime-mode.mjs +27 -98
  48. package/hub/team/cli/services/state-store.mjs +5 -20
  49. package/hub/team/codex-queue.mjs +385 -0
  50. package/hub/team/handoff.mjs +3 -34
  51. package/hub/team/headless.mjs +108 -1296
  52. package/hub/team/orchestrator.mjs +4 -30
  53. package/hub/team/pane.mjs +6 -28
  54. package/hub/team/psmux.mjs +3 -34
  55. package/hub/team/remote-session.mjs +1 -2
  56. package/hub/team/session-context.mjs +1 -1
  57. package/hub/team/session.mjs +7 -91
  58. package/hub/team/tui-core.mjs +1 -1
  59. package/hub/team/tui-viewer.mjs +11 -58
  60. package/hub/team/tui.mjs +8 -306
  61. package/hub/team/uds-orchestrator.mjs +2 -580
  62. package/hub/team/wt-manager.mjs +2 -2
  63. package/hub/workers/factory.mjs +6 -84
  64. package/hub/workers/interface.mjs +1 -7
  65. package/hub/workers/lib/jsonrpc-core.mjs +11 -0
  66. package/hub/workers/lib/jsonrpc-ws-uds.mjs +5 -8
  67. package/hud/cli-policy.mjs +0 -22
  68. package/hud/constants.mjs +13 -26
  69. package/hud/hud-qos-status.mjs +42 -54
  70. package/hud/providers/gemini.mjs +125 -20
  71. package/hud/renderers.mjs +101 -122
  72. package/hud/utils.mjs +10 -6
  73. package/package.json +7 -12
  74. package/scripts/__tests__/mcp-guard-engine-http-headers.test.mjs +6 -6
  75. package/scripts/__tests__/mcp-guard-engine-policy-sync.test.mjs +1 -4
  76. package/scripts/__tests__/mcp-guard-engine-proactive-sync.test.mjs +16 -21
  77. package/scripts/__tests__/mcp-guard-engine-stdio-sync.test.mjs +1 -1
  78. package/scripts/__tests__/mcp-guard-engine-sync-http-headers.test.mjs +2 -2
  79. package/scripts/__tests__/mcp-guard-engine.test.mjs +56 -63
  80. package/scripts/__tests__/spawn-trace.test.mjs +16 -0
  81. package/scripts/check-codex-config-stable.mjs +4 -37
  82. package/scripts/claude-login-detect.mjs +0 -1
  83. package/scripts/claudemd-sync.mjs +1 -28
  84. package/scripts/lib/agent-route-policy.mjs +143 -62
  85. package/scripts/lib/cli-claude.mjs +0 -14
  86. package/scripts/lib/env-probe.mjs +2 -131
  87. package/scripts/lib/gemini-profiles.mjs +2 -69
  88. package/scripts/lib/legacy-mcp-cleanup.mjs +283 -0
  89. package/scripts/lib/machine-profile.mjs +3 -17
  90. package/scripts/lib/mcp-filter.mjs +109 -53
  91. package/scripts/lib/mcp-guard-engine.mjs +40 -98
  92. package/scripts/lib/mcp-health.mjs +2 -2
  93. package/scripts/lib/mcp-server-catalog.mjs +2 -2
  94. package/scripts/lib/psmux-info.mjs +11 -59
  95. package/scripts/lib/session-presence.mjs +2 -100
  96. package/scripts/lib/skill-template.mjs +0 -245
  97. package/scripts/pack.mjs +8 -83
  98. package/scripts/preflight-cache.mjs +5 -18
  99. package/scripts/release/check-packages-mirror.mjs +2 -8
  100. package/scripts/release/version-manifest.json +9 -1
  101. package/scripts/setup.mjs +100 -417
  102. package/scripts/test-lock.mjs +0 -1
  103. package/scripts/tfx-route-post.mjs +5 -69
  104. package/scripts/tfx-route-worker.mjs +4 -79
  105. package/scripts/tfx-route.sh +169 -1495
  106. package/scripts/wt-cli.mjs +1 -2
  107. package/skills/tfx-auto/SKILL.md +123 -741
  108. package/skills/tfx-auto/references/consensus-schema.md +161 -0
  109. package/skills/tfx-auto/references/tmux-observation.md +97 -0
  110. package/skills/tfx-doctor/SKILL.md +5 -6
  111. package/skills/tfx-lead/SKILL.md +195 -0
  112. package/skills/tfx-live/SKILL.md +25 -11
  113. package/skills/tfx-remote/SKILL.md +24 -87
  114. package/skills/tfx-research/SKILL.md +13 -20
  115. package/skills/tfx-review/SKILL.md +33 -35
  116. package/skills/tfx-setup/SKILL.md +1 -1
  117. package/skills/tfx-ship/SKILL.md +6 -27
  118. package/skills/tfx-wt/SKILL.md +34 -68
  119. package/bin/tfx-doctor-tui.mjs +0 -7
  120. package/bin/tfx-setup-tui.mjs +0 -7
  121. package/config/routing-policy.json +0 -124
  122. package/docs/assets/consensus-flow.svg +0 -125
  123. package/docs/assets/deep-vs-light.svg +0 -193
  124. package/docs/assets/demo-dark.gif +0 -0
  125. package/docs/assets/demo-light.gif +0 -0
  126. package/docs/assets/demo-psmux.gif +0 -0
  127. package/hub/adaptive-diagnostic.mjs +0 -350
  128. package/hub/adaptive-inject.mjs +0 -224
  129. package/hub/adaptive-memory.mjs +0 -277
  130. package/hub/adaptive.mjs +0 -172
  131. package/hub/assign-callbacks.mjs +0 -170
  132. package/hub/delegator/contracts.mjs +0 -38
  133. package/hub/delegator/index.mjs +0 -14
  134. package/hub/delegator/schema/delegator-tools.schema.json +0 -250
  135. package/hub/delegator/service.mjs +0 -336
  136. package/hub/delegator/tool-definitions.mjs +0 -35
  137. package/hub/dynamic-routing-engine.mjs +0 -511
  138. package/hub/hitl.mjs +0 -202
  139. package/hub/hub-lifecycle.mjs +0 -248
  140. package/hub/lib/known-errors.json +0 -72
  141. package/hub/lib/memory-store.mjs +0 -1001
  142. package/hub/lib/phase-manager.mjs +0 -493
  143. package/hub/lib/risk-tier.mjs +0 -53
  144. package/hub/lib/ssh-command.mjs +0 -231
  145. package/hub/lib/staged-file-detect.mjs +0 -43
  146. package/hub/lib/state-snapshot.mjs +0 -293
  147. package/hub/lib/tfx-route-args.mjs +0 -353
  148. package/hub/lib/trace-recorder.mjs +0 -153
  149. package/hub/lib/uuidv7.mjs +0 -45
  150. package/hub/memory-doctor.mjs +0 -640
  151. package/hub/middleware/quota-middleware.mjs +0 -70
  152. package/hub/middleware/request-logger.mjs +0 -191
  153. package/hub/paths.mjs +0 -24
  154. package/hub/pipe.mjs +0 -825
  155. package/hub/pipeline/index.mjs +0 -163
  156. package/hub/pipeline/state.mjs +0 -191
  157. package/hub/pipeline/transitions.mjs +0 -131
  158. package/hub/promote-penalties.mjs +0 -138
  159. package/hub/public/dashboard.html +0 -416
  160. package/hub/reflexion.mjs +0 -290
  161. package/hub/role-contract.mjs +0 -314
  162. package/hub/router.mjs +0 -2026
  163. package/hub/routing-snapshot.mjs +0 -261
  164. package/hub/schema.sql +0 -209
  165. package/hub/server.mjs +0 -3048
  166. package/hub/session-fingerprint.mjs +0 -418
  167. package/hub/state.mjs +0 -300
  168. package/hub/store-adapter.mjs +0 -182
  169. package/hub/store.mjs +0 -1736
  170. package/hub/team/build-worker-prompt.mjs +0 -92
  171. package/hub/team/check-mcp-hub.mjs +0 -49
  172. package/hub/team/claude-native-bridge.mjs +0 -987
  173. package/hub/team/cli/commands/start/start-in-process.mjs +0 -50
  174. package/hub/team/cli/commands/start/start-wt.mjs +0 -86
  175. package/hub/team/cli/services/hub-client.mjs +0 -264
  176. package/hub/team/cli/services/native-control.mjs +0 -145
  177. package/hub/team/codex-review.mjs +0 -520
  178. package/hub/team/conductor-mesh-bridge.mjs +0 -122
  179. package/hub/team/conductor-registry.mjs +0 -121
  180. package/hub/team/conductor.mjs +0 -1529
  181. package/hub/team/consensus-meta.mjs +0 -176
  182. package/hub/team/cto-auto-collect.mjs +0 -115
  183. package/hub/team/cto-pull.mjs +0 -31
  184. package/hub/team/daemon-pty-tmux-bridge.mjs +0 -146
  185. package/hub/team/dashboard.mjs +0 -322
  186. package/hub/team/event-log.mjs +0 -84
  187. package/hub/team/execution-mode.mjs +0 -324
  188. package/hub/team/extract-completion-payload.mjs +0 -110
  189. package/hub/team/headless-bridge-session.mjs +0 -17
  190. package/hub/team/health-probe.mjs +0 -435
  191. package/hub/team/interactive-native-launcher.mjs +0 -119
  192. package/hub/team/interactive-native-worker.mjs +0 -80
  193. package/hub/team/interactive-tui-transport.mjs +0 -479
  194. package/hub/team/launcher-template.mjs +0 -144
  195. package/hub/team/lead-control.mjs +0 -191
  196. package/hub/team/mcp-selector.mjs +0 -145
  197. package/hub/team/native-supervisor.mjs +0 -460
  198. package/hub/team/native.mjs +0 -727
  199. package/hub/team/recovery-store.mjs +0 -149
  200. package/hub/team/remote-probe.mjs +0 -292
  201. package/hub/team/remote-watcher.mjs +0 -506
  202. package/hub/team/runtime-strategy.mjs +0 -200
  203. package/hub/team/schemas/worker-completion.json +0 -39
  204. package/hub/team/sentinel-capture.mjs +0 -127
  205. package/hub/team/session-sync.mjs +0 -316
  206. package/hub/team/swarm-cli.mjs +0 -390
  207. package/hub/team/swarm-hypervisor.mjs +0 -2206
  208. package/hub/team/swarm-intent.mjs +0 -216
  209. package/hub/team/swarm-locks.mjs +0 -335
  210. package/hub/team/swarm-planner.mjs +0 -371
  211. package/hub/team/swarm-preflight.mjs +0 -366
  212. package/hub/team/swarm-reconciler.mjs +0 -242
  213. package/hub/team/synapse-cli.mjs +0 -458
  214. package/hub/team/synapse-http.mjs +0 -311
  215. package/hub/team/synapse-registry.mjs +0 -618
  216. package/hub/team/tui-remote-adapter.mjs +0 -377
  217. package/hub/team/tui-synapse.mjs +0 -223
  218. package/hub/team/worker-completion-validator.mjs +0 -79
  219. package/hub/team/worker-sandbox.mjs +0 -125
  220. package/hub/team/worktree-lifecycle.mjs +0 -639
  221. package/hub/team/wt-templates.json +0 -43
  222. package/hub/team-bridge.mjs +0 -27
  223. package/hub/tools.mjs +0 -900
  224. package/hub/workers/antigravity-route-worker.mjs +0 -179
  225. package/hub/workers/codex-app-server-worker.mjs +0 -1067
  226. package/hub/workers/delegator-mcp.mjs +0 -1098
  227. package/hub/workers/lib/jsonrpc-stdio.mjs +0 -204
  228. package/mesh/index.mjs +0 -69
  229. package/mesh/mesh-budget.mjs +0 -137
  230. package/mesh/mesh-heartbeat.mjs +0 -100
  231. package/mesh/mesh-protocol.mjs +0 -96
  232. package/mesh/mesh-queue.mjs +0 -163
  233. package/mesh/mesh-registry.mjs +0 -78
  234. package/mesh/mesh-router.mjs +0 -77
  235. package/scripts/__tests__/skill-surface.test.mjs +0 -93
  236. package/scripts/cache-doctor.mjs +0 -156
  237. package/scripts/cache-warmup.mjs +0 -630
  238. package/scripts/completions/tfx.bash +0 -74
  239. package/scripts/completions/tfx.fish +0 -57
  240. package/scripts/completions/tfx.zsh +0 -98
  241. package/scripts/demo.mjs +0 -197
  242. package/scripts/doctor-dynamic-routing.mjs +0 -87
  243. package/scripts/hub-ensure.mjs +0 -842
  244. package/scripts/hub-watchdog.mjs +0 -90
  245. package/scripts/lib/claudemd-scanner.mjs +0 -223
  246. package/scripts/lib/context.mjs +0 -67
  247. package/scripts/lib/dynamic-route-cli.mjs +0 -107
  248. package/scripts/lib/handoff.mjs +0 -187
  249. package/scripts/lib/logger.mjs +0 -122
  250. package/scripts/lib/stealth-fetch.mjs +0 -176
  251. package/scripts/notion-read.mjs +0 -686
  252. package/scripts/snapshot-codex-state.mjs +0 -37
  253. package/scripts/snapshot-gemini-state.mjs +0 -37
  254. package/scripts/sync-codex-auth.mjs +0 -71
  255. package/scripts/sync-hub-mcp-settings.mjs +0 -697
  256. package/skills/_templates/base.md +0 -4
  257. package/skills/_templates/deep.md +0 -6
  258. package/skills/shared/telemetry-segment.md +0 -6
  259. package/tui/core.mjs +0 -266
  260. package/tui/doctor.mjs +0 -375
  261. package/tui/monitor-data.mjs +0 -152
  262. package/tui/monitor.mjs +0 -318
  263. package/tui/setup.mjs +0 -506
package/bin/triflux.mjs CHANGED
@@ -1,15 +1,12 @@
1
1
  #!/usr/bin/env node
2
2
  import { setTimeout as delay } from "node:timers/promises";
3
- import { execFileSync, execSync, spawn } from "child_process";
3
+ import { execFileSync, execSync } from "child_process";
4
4
  // triflux CLI — setup, doctor, version
5
5
  import {
6
- appendFileSync,
7
6
  chmodSync,
8
- closeSync,
9
7
  copyFileSync,
10
8
  existsSync,
11
9
  mkdirSync,
12
- openSync,
13
10
  readdirSync,
14
11
  readFileSync,
15
12
  readSync,
@@ -22,18 +19,11 @@ import {
22
19
  import { homedir } from "os";
23
20
  import { dirname, join, resolve } from "path";
24
21
  import { fileURLToPath } from "url";
25
- import { loadDelegatorSchemaBundle } from "../hub/delegator/tool-definitions.mjs";
26
22
  import { inspectClaudeRuntimeFlags } from "../hub/diagnostics/claude-runtime-flags.mjs";
27
23
  import {
28
24
  checkNetworkAvailability,
29
25
  validateRuntimeCachePaths,
30
26
  } from "../hub/lib/cache-guard.mjs";
31
- import {
32
- decideDispatchMode,
33
- parseArgs as parseRouteArgs,
34
- } from "../hub/lib/tfx-route-args.mjs";
35
- import { getPipelineStateDbPath } from "../hub/pipeline/state.mjs";
36
- import { getVersionHash } from "../hub/state.mjs";
37
27
  import {
38
28
  detectMultiplexer,
39
29
  getSessionAttachedCount,
@@ -46,9 +36,12 @@ import {
46
36
  inspectMacTimeoutDependency,
47
37
  } from "../scripts/lib/doctor-env-checks.mjs";
48
38
  import { ensureGeminiProfiles } from "../scripts/lib/gemini-profiles.mjs";
49
- import { serializeHandoff } from "../scripts/lib/handoff.mjs";
50
39
  import { cleanupLegacyHooks } from "../scripts/lib/legacy-hook-cleanup.mjs";
51
- import { cleanupLegacyMcp } from "../scripts/lib/legacy-mcp-cleanup.mjs";
40
+ import {
41
+ cleanupLegacyMcp,
42
+ cleanupTfxHub,
43
+ findProjectHubEntries,
44
+ } from "../scripts/lib/legacy-mcp-cleanup.mjs";
52
45
  import {
53
46
  addRegistryServer,
54
47
  createDefaultRegistry,
@@ -65,14 +58,11 @@ import {
65
58
  formatPsmuxUpdateGuidance,
66
59
  probePsmuxSupport,
67
60
  } from "../scripts/lib/psmux-info.mjs";
68
- import { main as stealthFetchMain } from "../scripts/lib/stealth-fetch.mjs";
69
61
  import {
70
62
  applyStatusLine,
71
- buildWindowsHubAutostartCommand,
72
- ensureCodexHubServerConfig,
73
63
  ensureCodexProfiles,
64
+ ensureTrifluxMods,
74
65
  getVersion,
75
- getWindowsHubAutostartStatus,
76
66
  isLocalDevSkillDir,
77
67
  isSkillSupportedOnPlatform,
78
68
  LEGACY_CODEX_MODELS,
@@ -108,22 +98,16 @@ const GREEN_BRIGHT = "\x1b[38;5;82m";
108
98
  const RED_BRIGHT = "\x1b[38;5;196m";
109
99
 
110
100
  // ── 브랜드 요소 ──
111
- const _BRAND = `${AMBER}${BOLD}triflux${RESET}`;
112
101
  const VER = `${DIM}v${PKG.version}${RESET}`;
113
102
  const LINE = `${GRAY}${"─".repeat(48)}${RESET}`;
114
- const _DOT = `${GRAY}·${RESET}`;
115
103
  const STALE_TEAM_MAX_AGE_SEC = 3600;
116
104
  const DEFAULT_TMUX_CLEANUP_PREFIX = "tfx-*";
117
105
  const DEFAULT_TMUX_CLEANUP_AGE_MIN = 60;
118
106
  const ANSI_PATTERN = /\x1B\[[0-?]*[ -/]*[@-~]/g;
119
- const HUB_DEFAULT_PORT = 27888;
120
- const DOCTOR_HUB_PID_FILE = join(CLAUDE_DIR, "cache", "tfx-hub", "hub.pid");
121
107
 
122
- const _EXIT_SUCCESS = 0;
123
108
  const EXIT_ERROR = 1;
124
109
  const EXIT_ARG_ERROR = 2;
125
110
  const EXIT_CLI_MISSING = 3;
126
- const EXIT_HUB_ERROR = 4;
127
111
  const EXIT_CONFIG_ERROR = 5;
128
112
 
129
113
  const RAW_ARGS = process.argv.slice(2);
@@ -131,64 +115,25 @@ const JSON_OUTPUT = RAW_ARGS.includes("--json");
131
115
  const NORMALIZED_ARGS = RAW_ARGS.filter((arg) => arg !== "--json");
132
116
 
133
117
  const CLI_COMMAND_SCHEMAS = Object.freeze({
134
- auto: {
135
- usage:
136
- "tfx auto [--cli auto|codex|antigravity|claude] [--mode quick|deep|consensus|live] [--rounds N] [--parallel 1|N|swarm] [--json]",
137
- description:
138
- "tfx-auto 라우팅 결정을 CLI에서 미리보기/직렬화 (실행 skill front door와 같은 flag surface)",
139
- options: [
140
- {
141
- name: "--cli <name>",
142
- type: "string",
143
- description: "실행 lane 강제: auto|codex|antigravity|claude",
144
- },
145
- {
146
- name: "--mode <name>",
147
- type: "string",
148
- description: "라우팅 모드: quick|deep|consensus|live",
149
- },
150
- {
151
- name: "--rounds <N>",
152
- type: "number",
153
- description: "live peer 왕복 횟수 (기본 4, --mode live 전용)",
154
- },
155
- {
156
- name: "--parallel <1|N|swarm>",
157
- type: "string",
158
- description: "단일/로컬 병렬/PRD swarm 라우팅 힌트",
159
- },
160
- {
161
- name: "--json",
162
- type: "boolean",
163
- description: "parse 결과와 dispatch 결정을 JSON으로 출력",
164
- },
165
- ],
166
- },
167
118
  setup: {
168
- usage: "tfx setup [--dry-run] [--enable-hub-autostart]",
119
+ usage: "tfx setup [--dry-run] [--mods]",
169
120
  description: "파일 동기화 + HUD/MCP 설정",
170
121
  options: [
171
122
  {
172
- name: "--dry-run",
123
+ name: "--mods",
173
124
  type: "boolean",
174
- description: "실제 변경 없이 예정 작업을 JSON으로 출력",
125
+ description: "Claude Code mods 설치 (2.1.287 이상)",
175
126
  },
176
127
  {
177
- name: "--enable-hub-autostart",
128
+ name: "--dry-run",
178
129
  type: "boolean",
179
- description:
180
- "Windows 로그인 시 tfx-hub를 보장하는 Task Scheduler 항목 등록",
130
+ description: "실제 변경 없이 예정 작업을 JSON으로 출력",
181
131
  },
182
132
  ],
183
133
  },
184
- "stealth-fetch": {
185
- usage: "tfx stealth-fetch <url>",
186
- description:
187
- "cloakbrowser 기반 단일 URL fetch (http/https only, JSON stdout)",
188
- },
189
134
  doctor: {
190
135
  usage:
191
- "tfx doctor [--fix] [--reset] [--audit] [--diagnose] [--purge-logs] [--dynamic-routing] [--cleanup-stale-hubs --dry-run|--apply] [--cleanup-stale-tmux --prefix tfx-* --age-min N --dry-run|--apply] [--json]",
136
+ "tfx doctor [--fix] [--reset] [--audit] [--diagnose] [--purge-logs] [--cleanup-stale-tmux --prefix tfx-* --age-min N --dry-run|--apply] [--json]",
192
137
  description: "설치 상태 진단 및 자동 복구",
193
138
  options: [
194
139
  {
@@ -218,18 +163,6 @@ const CLI_COMMAND_SCHEMAS = Object.freeze({
218
163
  description:
219
164
  "--fix 와 함께 사용. cli-issues.jsonl 에서 7일 초과 항목 물리 삭제 (#144)",
220
165
  },
221
- {
222
- name: "--dynamic-routing",
223
- type: "boolean",
224
- description:
225
- "Phase 1 dynamic routing 상태 진단 (env / policy / snapshot cache / preview decision)",
226
- },
227
- {
228
- name: "--cleanup-stale-hubs",
229
- type: "boolean",
230
- description:
231
- "PPID=1 hub/server.mjs 후보를 보고하고 opt-in 정리 모드를 활성화",
232
- },
233
166
  {
234
167
  name: "--cleanup-stale-tmux",
235
168
  type: "boolean",
@@ -249,14 +182,12 @@ const CLI_COMMAND_SCHEMAS = Object.freeze({
249
182
  {
250
183
  name: "--dry-run",
251
184
  type: "boolean",
252
- description:
253
- "--cleanup-stale-hubs/--cleanup-stale-tmux 와 함께 사용. 정리 후보만 표시",
185
+ description: "--cleanup-stale-tmux 와 함께 사용. 정리 후보만 표시",
254
186
  },
255
187
  {
256
188
  name: "--apply",
257
189
  type: "boolean",
258
- description:
259
- "--cleanup-stale-hubs/--cleanup-stale-tmux 와 함께 사용. stale 대상 종료",
190
+ description: "--cleanup-stale-tmux 와 함께 사용. stale 대상 종료",
260
191
  },
261
192
  {
262
193
  name: "--json",
@@ -276,38 +207,6 @@ const CLI_COMMAND_SCHEMAS = Object.freeze({
276
207
  },
277
208
  ],
278
209
  },
279
- handoff: {
280
- usage:
281
- "tfx handoff [--target local|remote] [--decision <text>] [--decision-file <path>] [--output <path>] [--json]",
282
- description: "현재 작업 컨텍스트를 세션 핸드오프 프롬프트로 직렬화",
283
- options: [
284
- {
285
- name: "--target",
286
- type: "string",
287
- description: "주입 대상 (local|remote, 기본값 remote)",
288
- },
289
- {
290
- name: "--decision",
291
- type: "string",
292
- description: "핸드오프 결정사항 (반복 지정 가능)",
293
- },
294
- {
295
- name: "--decision-file",
296
- type: "string",
297
- description: "결정사항 파일 (라인/불릿 단위)",
298
- },
299
- {
300
- name: "--output",
301
- type: "string",
302
- description: "생성한 핸드오프 프롬프트 저장 경로",
303
- },
304
- {
305
- name: "--json",
306
- type: "boolean",
307
- description: "핸드오프 결과를 JSON으로 출력",
308
- },
309
- ],
310
- },
311
210
  list: {
312
211
  usage: "tfx list [--json]",
313
212
  description: "패키지 스킬과 사용자 스킬 목록 표시",
@@ -319,17 +218,6 @@ const CLI_COMMAND_SCHEMAS = Object.freeze({
319
218
  },
320
219
  ],
321
220
  },
322
- schema: {
323
- usage: "tfx schema [command-or-tool]",
324
- description: "CLI 커맨드 파라미터와 Hub delegator schema 번들 출력",
325
- options: [
326
- {
327
- name: "command-or-tool",
328
- type: "string",
329
- description: "예: doctor, setup, delegate, delegate-reply, status",
330
- },
331
- ],
332
- },
333
221
  mcp: {
334
222
  usage: "tfx mcp <list|sync|add|remove> [--json]",
335
223
  description: "MCP registry 상태 확인 및 중앙 동기화",
@@ -378,103 +266,6 @@ const CLI_COMMAND_SCHEMAS = Object.freeze({
378
266
  },
379
267
  },
380
268
  },
381
- synapse: {
382
- usage: "tfx synapse status [--json] [--registry <path>]",
383
- description: "Synapse v1 세션 레지스트리 조회 (활성 스웜 세션 목록)",
384
- subcommands: {
385
- status: "활성 세션 테이블 표시 (host/branch/dirty/state/task)",
386
- },
387
- options: [
388
- {
389
- name: "--json",
390
- type: "boolean",
391
- description: "구조화된 JSON 출력",
392
- },
393
- {
394
- name: "--registry",
395
- type: "string",
396
- description: "registry 파일 경로 오버라이드",
397
- },
398
- ],
399
- },
400
- swarm: {
401
- usage:
402
- "tfx swarm [run|preflight] <prd-path> [--dry-run|--json|--filter <shard>]",
403
- description: "PRD 기반 멀티모델 x 멀티기기 스웜 실행 (#93)",
404
- subcommands: {
405
- run: "tfx swarm run <prd-path> — verb 명시 실행 (기본 경로와 동일)",
406
- preflight:
407
- "tfx swarm preflight <prd-path> [--json] — 실행 전 go/no-go 리포트 출력",
408
- plan: "tfx swarm plan <prd-path> [--json] — 실행 없이 계획만 출력",
409
- list: "tfx swarm list [--json] — 활성 스웜 세션 조회 (synapse status)",
410
- status: "tfx swarm status [--json] — list alias",
411
- },
412
- options: [
413
- {
414
- name: "--dry-run",
415
- type: "boolean",
416
- description: "PRD 분석만 수행, shard를 실행하지 않음",
417
- },
418
- {
419
- name: "--json",
420
- type: "boolean",
421
- description: "구조화된 JSON 출력",
422
- },
423
- {
424
- name: "--filter",
425
- type: "string",
426
- description: "특정 shard만 실행 (이름 매칭)",
427
- },
428
- {
429
- name: "--max-restarts",
430
- type: "number",
431
- description: "shard별 최대 재시작 횟수 (기본 2)",
432
- },
433
- {
434
- name: "--logs-dir",
435
- type: "string",
436
- description: "이벤트 로그 출력 디렉토리 오버라이드",
437
- },
438
- ],
439
- },
440
- why: {
441
- usage: "tfx why <path> [--json]",
442
- description: "해당 경로의 마지막 커밋에서 X-Intent 트레일러 추출",
443
- options: [
444
- {
445
- name: "path",
446
- type: "string",
447
- description: "intent를 조회할 파일 경로",
448
- },
449
- {
450
- name: "--json",
451
- type: "boolean",
452
- description: "구조화된 JSON 출력",
453
- },
454
- ],
455
- },
456
- hub: {
457
- usage: "tfx hub <start|stop|status|ensure> [--port N] [--json]",
458
- description: "tfx-hub 프로세스 제어",
459
- subcommands: {
460
- start: { usage: "tfx hub start [--port N]" },
461
- stop: { usage: "tfx hub stop" },
462
- ensure: {
463
- usage: "tfx hub ensure [--port N] [--json]",
464
- description: "헬스체크 + 자동 시작 (idempotent)",
465
- },
466
- status: {
467
- usage: "tfx hub status [--json]",
468
- options: [
469
- {
470
- name: "--json",
471
- type: "boolean",
472
- description: "허브 상태를 JSON으로 출력",
473
- },
474
- ],
475
- },
476
- },
477
- },
478
269
  cto: {
479
270
  usage: "tfx cto <collect|status|hygiene> [options]",
480
271
  description: "repo-local authority layer console",
@@ -491,9 +282,9 @@ const CLI_COMMAND_SCHEMAS = Object.freeze({
491
282
  description: "멀티-CLI 팀 모드",
492
283
  options: [
493
284
  {
494
- name: "--dashboard",
495
- type: "boolean",
496
- description: "headless dashboard viewer 표시 (기본값: 켜짐)",
285
+ name: "--teammate-mode",
286
+ type: "string",
287
+ description: "실행 모드: auto|headless|tmux|psmux (Windows)",
497
288
  },
498
289
  {
499
290
  name: "--no-dashboard",
@@ -537,74 +328,6 @@ const CLI_COMMAND_SCHEMAS = Object.freeze({
537
328
  },
538
329
  ],
539
330
  },
540
- "codex-team": {
541
- usage:
542
- "tfx codex-team [status|debug|send|attach|stop|<task>] [--layout 1xN|Nx1] [--json]",
543
- description:
544
- "Codex lead + Codex workers 기본값으로 tfx multi 팀 모드를 시작/제어",
545
- subcommands: {
546
- status: "현재 Codex team 상태 확인",
547
- debug: "최근 로그/상태 진단 출력",
548
- send: "워커에게 메시지 전송: tfx codex-team send <N> <msg>",
549
- attach: "팀 pane/session attach",
550
- stop: "팀 세션 정리",
551
- },
552
- options: [
553
- {
554
- name: "--layout <shape>",
555
- type: "string",
556
- description: "기본 1xN. Nx1 등 team layout 전달",
557
- },
558
- {
559
- name: "--json",
560
- type: "boolean",
561
- description: "지원 subcommand의 출력을 JSON으로 전환",
562
- },
563
- ],
564
- },
565
- "notion-read": {
566
- usage: "tfx notion-read <notion-url-or-page-id> [options]",
567
- description: "Notion page/database를 markdown/JSON으로 읽기 (nr alias)",
568
- aliases: ["nr"],
569
- options: [
570
- {
571
- name: "--json",
572
- type: "boolean",
573
- description: "가능한 경우 구조화된 JSON 출력",
574
- },
575
- ],
576
- },
577
- review: {
578
- usage:
579
- "tfx review [ref] [--base <ref>] [--timeout <seconds>] [--shard off|per-file] [--json]",
580
- description: "Codex 기반 git diff review 실행",
581
- options: [
582
- {
583
- name: "--base <ref>",
584
- type: "string",
585
- description: "비교 기준 ref",
586
- },
587
- {
588
- name: "--timeout <seconds>",
589
- type: "number",
590
- description: "review 실행 timeout (기본 180)",
591
- },
592
- {
593
- name: "--shard <mode>",
594
- type: "string",
595
- description: "off 또는 per-file",
596
- },
597
- {
598
- name: "--json",
599
- type: "boolean",
600
- description: "review 결과 JSON 출력",
601
- },
602
- ],
603
- },
604
- monitor: {
605
- usage: "tfx monitor",
606
- description: "터미널 TUI 모니터 실행",
607
- },
608
331
  });
609
332
 
610
333
  // ── 유틸리티 ──
@@ -705,512 +428,84 @@ function createCliError(
705
428
  return error;
706
429
  }
707
430
 
708
- function parseHubPort(value) {
709
- const parsed = Number.parseInt(String(value ?? ""), 10);
710
- return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
431
+ function inferExitCode(error) {
432
+ if (Number.isInteger(error?.exitCode)) return error.exitCode;
433
+ if (error?.code === "ENOENT") return EXIT_CLI_MISSING;
434
+ return EXIT_ERROR;
711
435
  }
712
436
 
713
- function isPidAliveForHub(pid, killFn = process.kill) {
714
- const resolvedPid = Number(pid);
715
- if (!Number.isFinite(resolvedPid) || resolvedPid <= 0) return false;
716
- try {
717
- killFn(resolvedPid, 0);
718
- return true;
719
- } catch (error) {
720
- return error?.code === "EPERM";
721
- }
437
+ function inferReason(error, exitCode) {
438
+ if (typeof error?.reason === "string" && error.reason) return error.reason;
439
+ if (exitCode === EXIT_ARG_ERROR) return "argError";
440
+ if (exitCode === EXIT_CLI_MISSING) return "cliMissing";
441
+ if (exitCode === EXIT_CONFIG_ERROR) return "configError";
442
+ return "error";
722
443
  }
723
444
 
724
- function isHubServerCommand(command) {
725
- return /(^|[\\/,\s])hub[\\/]server\.mjs(?=$|[\s"'`])/i.test(
726
- String(command || ""),
727
- );
445
+ function inferFix(error, exitCode) {
446
+ if (typeof error?.fix === "string" && error.fix) return error.fix;
447
+ if (exitCode === EXIT_ARG_ERROR) return "tfx --help";
448
+ if (exitCode === EXIT_CLI_MISSING)
449
+ return "필수 CLI를 설치한 뒤 `tfx doctor`로 상태를 다시 확인하세요.";
450
+ if (exitCode === EXIT_CONFIG_ERROR)
451
+ return "설정 파일 JSON/TOML 문법을 수정한 뒤 다시 실행하세요.";
452
+ return null;
728
453
  }
729
454
 
730
- function parsePortFromAddress(address) {
731
- const match = String(address || "").match(/:(\d+)(?:\s|$)/);
732
- return parseHubPort(match?.[1]);
733
- }
455
+ function handleFatalError(error, { json = false } = {}) {
456
+ const exitCode = inferExitCode(error);
457
+ const message = stripAnsi(error?.message || "알 수 없는 오류");
458
+ const reason = inferReason(error, exitCode);
459
+ const fix = inferFix(error, exitCode);
734
460
 
735
- export function parseDetachedHubProcessRows(output) {
736
- const rows = [];
737
- for (const line of String(output || "").split(/\r?\n/)) {
738
- const match = line.match(/^\s*(\d+)\s+(\d+)\s+(\d+)\s+(\S+)\s+(.+)$/);
739
- if (!match) continue;
740
- const [, pidText, ppidText, rssText, uptime, command] = match;
741
- const pid = Number.parseInt(pidText, 10);
742
- const ppid = Number.parseInt(ppidText, 10);
743
- const rssKb = Number.parseInt(rssText, 10);
744
- if (!Number.isFinite(pid) || !Number.isFinite(ppid)) continue;
745
- if (ppid !== 1) continue;
746
- if (!isHubServerCommand(command)) continue;
747
- rows.push({
748
- pid,
749
- ppid,
750
- rssKb: Number.isFinite(rssKb) ? rssKb : null,
751
- uptime,
752
- command: command.trim(),
461
+ if (json) {
462
+ printJson({
463
+ error: {
464
+ code: exitCode,
465
+ message,
466
+ reason,
467
+ ...(fix ? { fix } : {}),
468
+ },
753
469
  });
470
+ } else {
471
+ console.error(message);
472
+ if (fix) console.error(`fix: ${fix}`);
754
473
  }
755
- return rows;
474
+ process.exit(exitCode);
756
475
  }
757
476
 
758
- function queryDetachedHubProcessRows({
759
- platform = process.platform,
760
- execFile = execFileSync,
761
- } = {}) {
762
- if (platform === "win32") return [];
763
- try {
764
- const output = execFile("ps", ["-axo", "pid=,ppid=,rss=,etime=,command="], {
765
- encoding: "utf8",
766
- timeout: 5000,
767
- stdio: ["ignore", "pipe", "ignore"],
768
- windowsHide: true,
769
- });
770
- return parseDetachedHubProcessRows(output);
771
- } catch {
772
- return [];
477
+ function renderErrorMessage(message, fallback = "unknown error") {
478
+ if (typeof message === "string") {
479
+ const normalized = message.trim().toLowerCase();
480
+ if (
481
+ normalized.length > 0 &&
482
+ normalized !== "undefined" &&
483
+ normalized !== "null"
484
+ ) {
485
+ return message.trim();
486
+ }
773
487
  }
488
+ return fallback;
774
489
  }
775
490
 
776
- function parseLsofListeningPorts(output) {
777
- const ports = new Set();
778
- for (const line of String(output || "").split(/\r?\n/)) {
779
- if (!/\(LISTEN\)/i.test(line)) continue;
780
- const match = line.match(/TCP\s+\S+:(\d+)\s+\(LISTEN\)/i);
781
- const port = parseHubPort(match?.[1]) ?? parsePortFromAddress(line);
782
- if (port) ports.add(port);
783
- }
784
- return [...ports];
785
- }
786
-
787
- function queryListeningPortsForPid(
788
- pid,
789
- { platform = process.platform, execFile = execFileSync } = {},
790
- ) {
791
- const resolvedPid = Number(pid);
792
- if (!Number.isFinite(resolvedPid) || resolvedPid <= 0) return [];
793
- if (platform === "win32") return [];
794
- try {
795
- const output = execFile(
796
- "lsof",
797
- ["-nP", "-Pan", "-p", String(resolvedPid), "-iTCP", "-sTCP:LISTEN"],
798
- {
799
- encoding: "utf8",
800
- timeout: 5000,
801
- stdio: ["ignore", "pipe", "ignore"],
802
- windowsHide: true,
803
- },
804
- );
805
- return parseLsofListeningPorts(output);
806
- } catch {
807
- return [];
808
- }
809
- }
810
-
811
- function queryEstablishedCountForPid(
812
- pid,
813
- { platform = process.platform, execFile = execFileSync } = {},
814
- ) {
815
- const resolvedPid = Number(pid);
816
- if (!Number.isFinite(resolvedPid) || resolvedPid <= 0) return 0;
817
- if (platform === "win32") return 0;
818
- try {
819
- const output = execFile(
820
- "lsof",
821
- ["-nP", "-Pan", "-p", String(resolvedPid), "-iTCP", "-sTCP:ESTABLISHED"],
822
- {
823
- encoding: "utf8",
824
- timeout: 5000,
825
- stdio: ["ignore", "pipe", "ignore"],
826
- windowsHide: true,
827
- },
828
- );
829
- return Math.max(0, output.trim().split(/\r?\n/).filter(Boolean).length - 1);
830
- } catch {
831
- return 0;
832
- }
833
- }
834
-
835
- function queryPidCommand(
836
- pid,
837
- { platform = process.platform, execFile = execFileSync } = {},
838
- ) {
839
- const resolvedPid = Number(pid);
840
- if (!Number.isFinite(resolvedPid) || resolvedPid <= 0) return "";
841
- try {
842
- if (platform === "win32") return "";
843
- return execFile("ps", ["-p", String(resolvedPid), "-o", "command="], {
844
- encoding: "utf8",
845
- timeout: 5000,
846
- stdio: ["ignore", "pipe", "ignore"],
847
- windowsHide: true,
848
- }).trim();
849
- } catch {
850
- return "";
851
- }
852
- }
853
-
854
- function queryListeningPidByPort(
855
- port,
856
- { platform = process.platform, execFile = execFileSync } = {},
857
- ) {
858
- const targetPort = parseHubPort(port);
859
- if (!targetPort || platform === "win32") return null;
860
- try {
861
- const output = execFile(
862
- "lsof",
863
- ["-nP", "-iTCP:" + targetPort, "-sTCP:LISTEN", "-t"],
864
- {
865
- encoding: "utf8",
866
- timeout: 5000,
867
- stdio: ["ignore", "pipe", "ignore"],
868
- windowsHide: true,
869
- },
870
- );
871
- const pid = Number.parseInt(output.trim().split(/\r?\n/)[0] ?? "", 10);
872
- return Number.isFinite(pid) && pid > 0 ? pid : null;
873
- } catch {
874
- return null;
875
- }
876
- }
877
-
878
- async function fetchHubHealthForDoctor(
879
- host,
880
- port,
881
- { fetchImpl = fetch, timeoutMs = 1000 } = {},
882
- ) {
883
- try {
884
- const urlHost = String(host || "127.0.0.1").includes(":")
885
- ? `[${host}]`
886
- : host || "127.0.0.1";
887
- const response = await fetchImpl(`http://${urlHost}:${port}/health`, {
888
- signal: AbortSignal.timeout(timeoutMs),
889
- });
890
- if (!response.ok) return { ok: false, version: null };
891
- const body = await response.json().catch(() => null);
892
- return {
893
- ok: body?.ok === true,
894
- version: typeof body?.version === "string" ? body.version : null,
895
- raw: body,
896
- };
897
- } catch (error) {
898
- return { ok: false, version: null, error };
899
- }
900
- }
901
-
902
- function readHubPidInfo({
903
- pidFilePath = DOCTOR_HUB_PID_FILE,
904
- exists = existsSync,
905
- readFile = readFileSync,
906
- } = {}) {
907
- if (!exists(pidFilePath)) return null;
908
- try {
909
- return JSON.parse(readFile(pidFilePath, "utf8"));
910
- } catch {
911
- return null;
912
- }
913
- }
914
-
915
- async function resolveActiveHealthyHub({
916
- expectedVersion = getVersionHash(),
917
- pidFilePath = DOCTOR_HUB_PID_FILE,
918
- exists = existsSync,
919
- readFile = readFileSync,
920
- killFn = process.kill,
921
- platform = process.platform,
922
- execFile = execFileSync,
923
- fetchImpl = fetch,
924
- } = {}) {
925
- const info = readHubPidInfo({ pidFilePath, exists, readFile });
926
- const pid = Number(info?.pid);
927
- const port = parseHubPort(info?.port);
928
- const host =
929
- typeof info?.host === "string" && info.host.trim()
930
- ? info.host.trim()
931
- : "127.0.0.1";
932
- if (pid && port && isPidAliveForHub(pid, killFn)) {
933
- const command = queryPidCommand(pid, { platform, execFile });
934
- const health = await fetchHubHealthForDoctor(host, port, { fetchImpl });
935
- if (
936
- isHubServerCommand(command) &&
937
- health.ok &&
938
- health.version === expectedVersion
939
- ) {
940
- return { pid, port, host, version: health.version, source: "pid-file" };
941
- }
942
- }
943
-
944
- const defaultPid = queryListeningPidByPort(HUB_DEFAULT_PORT, {
945
- platform,
946
- execFile,
947
- });
948
- if (!defaultPid || !isPidAliveForHub(defaultPid, killFn)) return null;
949
- const command = queryPidCommand(defaultPid, { platform, execFile });
950
- if (!isHubServerCommand(command)) return null;
951
- const health = await fetchHubHealthForDoctor("127.0.0.1", HUB_DEFAULT_PORT, {
952
- fetchImpl,
953
- });
954
- if (!health.ok || health.version !== expectedVersion) return null;
955
- return {
956
- pid: defaultPid,
957
- port: HUB_DEFAULT_PORT,
958
- host: "127.0.0.1",
959
- version: health.version,
960
- source: "default-port",
961
- };
962
- }
963
-
964
- export async function inspectDetachedHubProcesses({
965
- expectedVersion = getVersionHash(),
966
- pidFilePath = DOCTOR_HUB_PID_FILE,
967
- exists = existsSync,
968
- readFile = readFileSync,
969
- killFn = process.kill,
970
- platform = process.platform,
971
- execFile = execFileSync,
972
- fetchImpl = fetch,
973
- } = {}) {
974
- const rows = queryDetachedHubProcessRows({ platform, execFile });
975
- const activeHealthy = await resolveActiveHealthyHub({
976
- expectedVersion,
977
- pidFilePath,
978
- exists,
979
- readFile,
980
- killFn,
981
- platform,
982
- execFile,
983
- fetchImpl,
984
- });
985
-
986
- const hubs = [];
987
- for (const row of rows) {
988
- const ports = queryListeningPortsForPid(row.pid, { platform, execFile });
989
- const established = queryEstablishedCountForPid(row.pid, {
990
- platform,
991
- execFile,
992
- });
993
- let version = null;
994
- let healthy = false;
995
- for (const port of ports) {
996
- const health = await fetchHubHealthForDoctor("127.0.0.1", port, {
997
- fetchImpl,
998
- });
999
- if (!health.ok) continue;
1000
- version = health.version;
1001
- healthy = true;
1002
- break;
1003
- }
1004
- const activeByPidFile = activeHealthy?.pid === row.pid;
1005
- const activeByConnection = established >= 1;
1006
- const isActiveHealthy = activeByPidFile || activeByConnection;
1007
- hubs.push({
1008
- ...row,
1009
- ports,
1010
- established,
1011
- version,
1012
- healthy: healthy || activeByConnection,
1013
- activeHealthy: isActiveHealthy,
1014
- activeReason: activeByPidFile
1015
- ? "pid-file-health"
1016
- : activeByConnection
1017
- ? "established-connection"
1018
- : null,
1019
- healthStatus: isActiveHealthy ? "healthy" : "stale",
1020
- staleCandidate: !isActiveHealthy,
1021
- });
1022
- }
1023
-
1024
- return {
1025
- expectedVersion,
1026
- activeHealthy,
1027
- hubs,
1028
- staleCandidates: hubs.filter((hub) => hub.staleCandidate),
1029
- };
1030
- }
1031
-
1032
- async function waitForHubProcessExit(
1033
- pid,
1034
- { killFn = process.kill, graceMs = 5000, pollMs = 100 } = {},
1035
- ) {
1036
- const deadline = Date.now() + Math.max(0, graceMs);
1037
- while (Date.now() <= deadline) {
1038
- if (!isPidAliveForHub(pid, killFn)) return true;
1039
- await delay(pollMs);
1040
- }
1041
- return !isPidAliveForHub(pid, killFn);
1042
- }
1043
-
1044
- async function retireDetachedHubPid(
1045
- pid,
1046
- { killFn = process.kill, graceMs = 5000, pollMs = 100 } = {},
1047
- ) {
1048
- if (!isPidAliveForHub(pid, killFn)) return { ok: true, reason: "dead" };
1049
- try {
1050
- killFn(pid, "SIGTERM");
1051
- } catch (error) {
1052
- return { ok: false, reason: "sigterm_failed", error };
1053
- }
1054
- if (await waitForHubProcessExit(pid, { killFn, graceMs, pollMs })) {
1055
- return { ok: true, reason: "sigterm" };
1056
- }
1057
- try {
1058
- killFn(pid, "SIGKILL");
1059
- } catch (error) {
1060
- return { ok: false, reason: "sigkill_failed", error };
1061
- }
1062
- const exited = await waitForHubProcessExit(pid, {
1063
- killFn,
1064
- graceMs: 1000,
1065
- pollMs,
1066
- });
1067
- return { ok: exited, reason: exited ? "sigkill" : "still_alive" };
1068
- }
1069
-
1070
- export async function cleanupDetachedHubProcesses({
1071
- hubs,
1072
- activeHealthy,
1073
- dryRun = true,
1074
- apply = false,
1075
- killFn = process.kill,
1076
- graceMs = 5000,
1077
- pollMs = 100,
1078
- } = {}) {
1079
- const results = [];
1080
- for (const hub of hubs || []) {
1081
- const classification =
1082
- activeHealthy?.pid === hub.pid ||
1083
- hub.activeHealthy ||
1084
- Number(hub.established) >= 1
1085
- ? "healthy"
1086
- : "stale";
1087
- if (classification === "healthy") {
1088
- results.push({
1089
- pid: hub.pid,
1090
- classification,
1091
- action: "excluded-active",
1092
- ok: true,
1093
- hub,
1094
- });
1095
- continue;
1096
- }
1097
- if (!apply || dryRun) {
1098
- results.push({
1099
- pid: hub.pid,
1100
- classification,
1101
- action: "dry-run-skip",
1102
- ok: true,
1103
- hub,
1104
- });
1105
- continue;
1106
- }
1107
- const retired = await retireDetachedHubPid(hub.pid, {
1108
- killFn,
1109
- graceMs,
1110
- pollMs,
1111
- });
1112
- results.push({
1113
- pid: hub.pid,
1114
- classification,
1115
- action: retired.ok ? "retired" : "failed",
1116
- ok: retired.ok,
1117
- reason: retired.reason,
1118
- hub,
1119
- });
1120
- }
1121
- return {
1122
- dryRun: !apply || dryRun,
1123
- results,
1124
- failed: results.filter((result) => result.ok === false).length,
1125
- retired: results.filter((result) => result.action === "retired").length,
1126
- skipped: results.filter((result) => result.action === "dry-run-skip")
1127
- .length,
1128
- excluded: results.filter((result) => result.action === "excluded-active")
1129
- .length,
1130
- };
1131
- }
1132
-
1133
- function inferExitCode(error) {
1134
- if (Number.isInteger(error?.exitCode)) return error.exitCode;
1135
- if (error?.code === "ENOENT") return EXIT_CLI_MISSING;
1136
- return EXIT_ERROR;
1137
- }
1138
-
1139
- function inferReason(error, exitCode) {
1140
- if (typeof error?.reason === "string" && error.reason) return error.reason;
1141
- if (exitCode === EXIT_ARG_ERROR) return "argError";
1142
- if (exitCode === EXIT_CLI_MISSING) return "cliMissing";
1143
- if (exitCode === EXIT_HUB_ERROR) return "hubError";
1144
- if (exitCode === EXIT_CONFIG_ERROR) return "configError";
1145
- return "error";
1146
- }
1147
-
1148
- function inferFix(error, exitCode) {
1149
- if (typeof error?.fix === "string" && error.fix) return error.fix;
1150
- if (exitCode === EXIT_ARG_ERROR) return "tfx --help";
1151
- if (exitCode === EXIT_CLI_MISSING)
1152
- return "필수 CLI를 설치한 뒤 `tfx doctor`로 상태를 다시 확인하세요.";
1153
- if (exitCode === EXIT_HUB_ERROR)
1154
- return "`tfx hub start`로 허브를 다시 시작하거나 설치 상태를 확인하세요.";
1155
- if (exitCode === EXIT_CONFIG_ERROR)
1156
- return "설정 파일 JSON/TOML 문법을 수정한 뒤 다시 실행하세요.";
1157
- return null;
1158
- }
1159
-
1160
- function handleFatalError(error, { json = false } = {}) {
1161
- const exitCode = inferExitCode(error);
1162
- const message = stripAnsi(error?.message || "알 수 없는 오류");
1163
- const reason = inferReason(error, exitCode);
1164
- const fix = inferFix(error, exitCode);
1165
-
1166
- if (json) {
1167
- printJson({
1168
- error: {
1169
- code: exitCode,
1170
- message,
1171
- reason,
1172
- ...(fix ? { fix } : {}),
1173
- },
1174
- });
1175
- } else {
1176
- console.error(message);
1177
- if (fix) console.error(`fix: ${fix}`);
1178
- }
1179
- process.exit(exitCode);
1180
- }
1181
-
1182
- function renderErrorMessage(message, fallback = "unknown error") {
1183
- if (typeof message === "string") {
1184
- const normalized = message.trim().toLowerCase();
1185
- if (
1186
- normalized.length > 0 &&
1187
- normalized !== "undefined" &&
1188
- normalized !== "null"
1189
- ) {
1190
- return message.trim();
1191
- }
1192
- }
1193
- return fallback;
1194
- }
1195
-
1196
- function which(cmd) {
1197
- try {
1198
- const result =
1199
- process.platform === "win32"
1200
- ? execFileSync("where", [cmd], {
1201
- encoding: "utf8",
1202
- timeout: 5000,
1203
- stdio: ["pipe", "pipe", "ignore"],
1204
- windowsHide: true,
1205
- })
1206
- : execFileSync("which", [cmd], {
1207
- encoding: "utf8",
1208
- timeout: 5000,
1209
- stdio: ["pipe", "pipe", "ignore"],
1210
- });
1211
- return result.trim().split(/\r?\n/)[0] || null;
1212
- } catch {
1213
- return null;
491
+ function which(cmd) {
492
+ try {
493
+ const result =
494
+ process.platform === "win32"
495
+ ? execFileSync("where", [cmd], {
496
+ encoding: "utf8",
497
+ timeout: 5000,
498
+ stdio: ["pipe", "pipe", "ignore"],
499
+ windowsHide: true,
500
+ })
501
+ : execFileSync("which", [cmd], {
502
+ encoding: "utf8",
503
+ timeout: 5000,
504
+ stdio: ["pipe", "pipe", "ignore"],
505
+ });
506
+ return result.trim().split(/\r?\n/)[0] || null;
507
+ } catch {
508
+ return null;
1214
509
  }
1215
510
  }
1216
511
 
@@ -1876,40 +1171,6 @@ function previewStatusLineAction() {
1876
1171
  };
1877
1172
  }
1878
1173
 
1879
- function previewMcpRegistrationActions(mcpUrl) {
1880
- const actions = [];
1881
-
1882
- if (which("codex")) {
1883
- actions.push({
1884
- type: "mcp-register",
1885
- cli: "codex",
1886
- target: "tfx-hub",
1887
- url: mcpUrl,
1888
- change: "check",
1889
- });
1890
- }
1891
- if (which("gemini")) {
1892
- actions.push({
1893
- type: "mcp-register",
1894
- cli: "gemini",
1895
- target: "tfx-hub",
1896
- url: mcpUrl,
1897
- change: "check",
1898
- });
1899
- }
1900
-
1901
- actions.push({
1902
- type: "mcp-register",
1903
- cli: "claude",
1904
- target: "tfx-hub",
1905
- path: join(process.cwd(), ".claude", "mcp.json"),
1906
- url: mcpUrl,
1907
- change: "check",
1908
- });
1909
-
1910
- return actions;
1911
- }
1912
-
1913
1174
  function buildSetupDryRunPlan() {
1914
1175
  const actions = [
1915
1176
  ...SYNC_MAP.map(({ src, dst, label }) =>
@@ -1926,19 +1187,7 @@ function buildSetupDryRunPlan() {
1926
1187
  windowsSandbox: codexProfiles.windowsSandbox,
1927
1188
  });
1928
1189
 
1929
- const defaultHubUrl = `http://127.0.0.1:${process.env.TFX_HUB_PORT || "27888"}/mcp`;
1930
- actions.push(...previewMcpRegistrationActions(defaultHubUrl));
1931
1190
  actions.push(previewStatusLineAction());
1932
- const autostart = getWindowsHubAutostartStatus();
1933
- actions.push({
1934
- type: "hub-autostart",
1935
- platform: process.platform,
1936
- taskName: autostart.taskName,
1937
- change: autostart.supported && !autostart.registered ? "available" : "noop",
1938
- registered: autostart.registered,
1939
- command: autostart.supported ? buildWindowsHubAutostartCommand() : null,
1940
- enableWith: "tfx setup --enable-hub-autostart",
1941
- });
1942
1191
 
1943
1192
  return {
1944
1193
  dry_run: true,
@@ -1999,7 +1248,7 @@ function cmdSetup(options = {}) {
1999
1248
  dryRun = false,
2000
1249
  fromUpdate = false,
2001
1250
  overrideVersion,
2002
- enableHubAutostart = false,
1251
+ mods = false,
2003
1252
  } = options;
2004
1253
  if (dryRun) {
2005
1254
  printJson(buildSetupDryRunPlan());
@@ -2018,6 +1267,11 @@ function cmdSetup(options = {}) {
2018
1267
  }
2019
1268
  // 이주가 막혀도 setup 은 계속한다. 남은 항목은 경고로 알린다.
2020
1269
  for (const warning of cleanupLegacyMcp().warnings) warn(warning);
1270
+ const hubCleanup = cleanupTfxHub({
1271
+ pluginRoot: existsSync(join(PKG_ROOT, ".git")) ? undefined : PKG_ROOT,
1272
+ });
1273
+ for (const warning of hubCleanup.warnings) warn(warning);
1274
+ if (hubCleanup.changed) ok("제거된 허브의 설정과 실행 흔적 정리");
2021
1275
  if (fromUpdate) refreshSetupCaches();
2022
1276
 
2023
1277
  console.log(`\n${BOLD}triflux setup${RESET}\n`);
@@ -2026,6 +1280,7 @@ function cmdSetup(options = {}) {
2026
1280
  syncFile(target.src, target.dst, target.label);
2027
1281
  }
2028
1282
  reportSkillSync();
1283
+ ensureTrifluxMods({ install: mods, log: console.log, warn });
2029
1284
 
2030
1285
  // ── psmux 기본 셸 자동 수정 (cmd.exe → PowerShell) ──
2031
1286
  if (process.platform === "win32" && which("psmux")) {
@@ -2114,79 +1369,10 @@ function cmdSetup(options = {}) {
2114
1369
  });
2115
1370
  }
2116
1371
 
2117
- // hub MCP 사전 등록 (서버 미실행이어도 설정만 등록 — hub start 시 즉시 사용 가능)
2118
- if (existsSync(join(PKG_ROOT, "hub", "server.mjs"))) {
2119
- const defaultHubUrl = `http://127.0.0.1:${process.env.TFX_HUB_PORT || "27888"}/mcp`;
2120
- autoRegisterMcp(defaultHubUrl, { codexEnabled: false });
2121
- summary.push({ item: "Hub MCP", status: "✅", detail: "등록됨" });
2122
- console.log("");
2123
- }
2124
-
2125
- if (process.platform === "win32") {
2126
- const status = getWindowsHubAutostartStatus();
2127
- if (enableHubAutostart) {
2128
- try {
2129
- const script = join(PKG_ROOT, "scripts", "setup.mjs");
2130
- execFileSync(
2131
- process.execPath,
2132
- [script, "--enable-hub-autostart", "--sync"],
2133
- {
2134
- stdio: ["ignore", "pipe", "pipe"],
2135
- timeout: 10000,
2136
- windowsHide: true,
2137
- },
2138
- );
2139
- // subprocess silent-catch 회귀 가드: schtasks /Query 로 실제 등록 재검증.
2140
- const verified = getWindowsHubAutostartStatus();
2141
- if (verified.registered) {
2142
- ok(`Hub autostart: ${verified.taskName} 등록됨`);
2143
- summary.push({
2144
- item: "Hub autostart",
2145
- status: "✅",
2146
- detail: `${verified.taskName} 등록됨`,
2147
- });
2148
- } else {
2149
- warn(
2150
- "Hub autostart 등록 실패: subprocess 성공했으나 /Query 에서 미발견",
2151
- );
2152
- summary.push({
2153
- item: "Hub autostart",
2154
- status: "⚠️",
2155
- detail: "등록 실패 (subprocess silent catch 의심)",
2156
- });
2157
- }
2158
- } catch (error) {
2159
- warn(`Hub autostart 등록 실패: ${renderErrorMessage(error.message)}`);
2160
- summary.push({
2161
- item: "Hub autostart",
2162
- status: "⚠️",
2163
- detail: "등록 실패",
2164
- });
2165
- }
2166
- } else if (status.registered) {
2167
- ok(`Hub autostart: ${status.taskName} 이미 등록됨`);
2168
- summary.push({
2169
- item: "Hub autostart",
2170
- status: "✅",
2171
- detail: "이미 등록됨",
2172
- });
2173
- } else {
2174
- warn(
2175
- "Hub autostart 미등록 — Codex 단독 시작 전 hub가 죽어 있으면 MCP가 실패할 수 있음",
2176
- );
2177
- info("등록: tfx setup --enable-hub-autostart");
2178
- summary.push({
2179
- item: "Hub autostart",
2180
- status: "⏭️",
2181
- detail: "미등록",
2182
- });
2183
- }
2184
- }
2185
-
2186
- // HUD statusLine 설정
2187
- console.log(`${CYAN}[HUD 설정]${RESET}`);
2188
- const settingsPath = join(CLAUDE_DIR, "settings.json");
2189
- const hudPath = join(CLAUDE_DIR, "hud", "hud-qos-status.mjs");
1372
+ // HUD statusLine 설정
1373
+ console.log(`${CYAN}[HUD 설정]${RESET}`);
1374
+ const settingsPath = join(CLAUDE_DIR, "settings.json");
1375
+ const hudPath = join(CLAUDE_DIR, "hud", "hud-qos-status.mjs");
2190
1376
 
2191
1377
  if (existsSync(hudPath)) {
2192
1378
  try {
@@ -2385,9 +1571,6 @@ async function cmdDoctor(options = {}) {
2385
1571
  fix = false,
2386
1572
  reset = false,
2387
1573
  purgeLogs = false,
2388
- cleanupStaleHubs = false,
2389
- cleanupStaleHubsDryRun = true,
2390
- cleanupStaleHubsApply = false,
2391
1574
  cleanupStaleTmux = false,
2392
1575
  cleanupStaleTmuxDryRun = true,
2393
1576
  cleanupStaleTmuxApply = false,
@@ -2402,7 +1585,6 @@ async function cmdDoctor(options = {}) {
2402
1585
  actions: [],
2403
1586
  legacy_hooks: { remaining: 0, removed: 0 },
2404
1587
  fsmonitorDaemons: { stale: 0, killed: 0 },
2405
- hubServers: { detached: 0, stale: 0, activeHealthy: null },
2406
1588
  tmuxSessions: {
2407
1589
  detached: 0,
2408
1590
  stale: 0,
@@ -2535,33 +1717,6 @@ async function cmdDoctor(options = {}) {
2535
1717
  warn("Codex 레이트 리밋 캐시 재생성 실패");
2536
1718
  }
2537
1719
  }
2538
- try {
2539
- const { buildAll } = await import("../scripts/cache-warmup.mjs");
2540
- const warmupSummary = buildAll({ cwd: process.cwd(), force: true });
2541
- if (warmupSummary.ok) {
2542
- report.actions.push({
2543
- type: "rebuild",
2544
- name: "warmup-caches",
2545
- status: "ok",
2546
- built: warmupSummary.built,
2547
- });
2548
- ok("Phase 1 웜업 캐시 재생성됨");
2549
- } else {
2550
- report.actions.push({
2551
- type: "rebuild",
2552
- name: "warmup-caches",
2553
- status: "failed",
2554
- });
2555
- warn("Phase 1 웜업 캐시 재생성 실패");
2556
- }
2557
- } catch {
2558
- report.actions.push({
2559
- type: "rebuild",
2560
- name: "warmup-caches",
2561
- status: "failed",
2562
- });
2563
- warn("Phase 1 웜업 캐시 재생성 실패");
2564
- }
2565
1720
  console.log(`\n ${LINE}`);
2566
1721
  console.log(
2567
1722
  ` ${GREEN_BRIGHT}${BOLD}✓ 캐시 초기화 + 재생성 완료${RESET}\n`,
@@ -2683,19 +1838,6 @@ async function cmdDoctor(options = {}) {
2683
1838
  }
2684
1839
  }
2685
1840
  if (cleaned === 0) info("에러 캐시 없음");
2686
- try {
2687
- const { fixCaches } = await import("../scripts/cache-doctor.mjs");
2688
- const cacheRepair = await fixCaches({ cwd: process.cwd() });
2689
- if (cacheRepair.fixed.length > 0 && cacheRepair.ok) {
2690
- ok(`웜업 캐시 자동 복구: ${cacheRepair.fixed.join(", ")}`);
2691
- } else if (cacheRepair.fixed.length > 0) {
2692
- warn(`웜업 캐시 자동 복구 실패: ${cacheRepair.fixed.join(", ")}`);
2693
- } else {
2694
- info("웜업 캐시: 이미 정상 상태");
2695
- }
2696
- } catch {
2697
- warn("웜업 캐시 자동 복구 실패");
2698
- }
2699
1841
  const registryStateForFix = inspectRegistry();
2700
1842
  if (registryStateForFix.valid) {
2701
1843
  try {
@@ -3278,45 +2420,6 @@ async function cmdDoctor(options = {}) {
3278
2420
  info(`수동: node ${join(PKG_ROOT, "scripts", "mcp-check.mjs")}`);
3279
2421
  }
3280
2422
 
3281
- // Phase 1 웜업 캐시
3282
- section("Warmup Cache");
3283
- try {
3284
- const { verifyCaches } = await import("../scripts/cache-doctor.mjs");
3285
- const cacheVerification = verifyCaches({ cwd: process.cwd() });
3286
- const brokenCaches = cacheVerification.results.filter(
3287
- (result) => result.status !== "ok",
3288
- );
3289
-
3290
- addDoctorCheck(report, {
3291
- name: "warmup-cache",
3292
- status: cacheVerification.ok ? "ok" : "issues",
3293
- files: cacheVerification.results.map((result) => ({
3294
- target: result.target,
3295
- status: result.status,
3296
- path: result.file,
3297
- })),
3298
- ...(cacheVerification.ok ? {} : { fix: "tfx doctor --fix" }),
3299
- });
3300
-
3301
- if (brokenCaches.length === 0) {
3302
- ok(`${cacheVerification.results.length}개 웜업 캐시 정상`);
3303
- } else {
3304
- warn(`${brokenCaches.length}개 웜업 캐시 이슈 발견`);
3305
- for (const entry of brokenCaches) {
3306
- info(`${entry.target}: ${entry.status}`);
3307
- }
3308
- if (!fix) issues += brokenCaches.length;
3309
- }
3310
- } catch (error) {
3311
- addDoctorCheck(report, {
3312
- name: "warmup-cache",
3313
- status: "invalid",
3314
- fix: "node scripts/cache-doctor.mjs --fix",
3315
- });
3316
- warn(`웜업 캐시 검사 실패: ${error.message}`);
3317
- issues++;
3318
- }
3319
-
3320
2423
  // CLI 이슈 트래커
3321
2424
  section("CLI Issues");
3322
2425
  const issuesFile = join(CLAUDE_DIR, "cache", "cli-issues.jsonl");
@@ -3610,98 +2713,6 @@ async function cmdDoctor(options = {}) {
3610
2713
  issues += detachedTmuxReport.staleCandidates.length;
3611
2714
  }
3612
2715
 
3613
- // detached tfx-hub/server.mjs 누적 감지 및 opt-in 정리
3614
- section("Hub Servers");
3615
- try {
3616
- const hubReport = await inspectDetachedHubProcesses();
3617
- report.hubServers = {
3618
- detached: hubReport.hubs.length,
3619
- stale: hubReport.staleCandidates.length,
3620
- activeHealthy: hubReport.activeHealthy,
3621
- expectedVersion: hubReport.expectedVersion,
3622
- hubs: hubReport.hubs.map((hub) => ({
3623
- pid: hub.pid,
3624
- ppid: hub.ppid,
3625
- version: hub.version,
3626
- uptime: hub.uptime,
3627
- ports: hub.ports,
3628
- established: hub.established,
3629
- rssKb: hub.rssKb,
3630
- activeHealthy: hub.activeHealthy,
3631
- activeReason: hub.activeReason,
3632
- healthStatus: hub.healthStatus,
3633
- staleCandidate: hub.staleCandidate,
3634
- })),
3635
- };
3636
- addDoctorCheck(report, {
3637
- name: "hub-server-processes",
3638
- status: hubReport.staleCandidates.length > 0 ? "warning" : "ok",
3639
- detached: hubReport.hubs.length,
3640
- stale: hubReport.staleCandidates.length,
3641
- activeHealthy: hubReport.activeHealthy,
3642
- });
3643
-
3644
- if (hubReport.hubs.length === 0) {
3645
- ok("PPID=1 hub/server.mjs 없음");
3646
- } else {
3647
- for (const hub of hubReport.hubs) {
3648
- const portLabel = hub.ports.length > 0 ? hub.ports.join(",") : "?";
3649
- const versionLabel = hub.version || "unknown";
3650
- const rssLabel = hub.rssKb == null ? "?" : `${hub.rssKb}KB`;
3651
- const activeLabel = hub.activeHealthy
3652
- ? ` healthy excluded${hub.activeReason ? ` (${hub.activeReason})` : ""}`
3653
- : " stale candidate";
3654
- const line = `PID=${hub.pid} PPID=${hub.ppid} status=${hub.healthStatus} version=${versionLabel} uptime=${hub.uptime} port=${portLabel} ESTABLISHED=${hub.established} RSS=${rssLabel}${activeLabel}`;
3655
- if (hub.activeHealthy) ok(line);
3656
- else warn(line);
3657
- }
3658
- }
3659
-
3660
- if (cleanupStaleHubs) {
3661
- const cleanupResult = await cleanupDetachedHubProcesses({
3662
- hubs: hubReport.hubs,
3663
- activeHealthy: hubReport.activeHealthy,
3664
- dryRun: cleanupStaleHubsDryRun,
3665
- apply: cleanupStaleHubsApply,
3666
- });
3667
- report.actions.push({
3668
- type: "cleanup-stale-hubs",
3669
- status: cleanupResult.failed > 0 ? "failed" : "ok",
3670
- dryRun: cleanupResult.dryRun,
3671
- retired: cleanupResult.retired,
3672
- skipped: cleanupResult.skipped,
3673
- excluded: cleanupResult.excluded,
3674
- failed: cleanupResult.failed,
3675
- });
3676
- for (const result of cleanupResult.results) {
3677
- if (result.action === "excluded-active") {
3678
- ok(`dry-run healthy hub excluded: PID=${result.pid}`);
3679
- } else if (result.action === "dry-run-skip") {
3680
- info(`dry-run stale hub target: PID=${result.pid}`);
3681
- } else if (result.action === "retired") {
3682
- ok(`stale hub retired: PID=${result.pid} (${result.reason})`);
3683
- } else {
3684
- fail(`stale hub cleanup failed: PID=${result.pid}`);
3685
- }
3686
- }
3687
- issues += cleanupResult.failed;
3688
- if (cleanupResult.dryRun && hubReport.staleCandidates.length > 0) {
3689
- issues += hubReport.staleCandidates.length;
3690
- }
3691
- } else if (hubReport.staleCandidates.length > 0) {
3692
- info("정리: tfx doctor --cleanup-stale-hubs --dry-run|--apply");
3693
- issues += hubReport.staleCandidates.length;
3694
- }
3695
- } catch (error) {
3696
- addDoctorCheck(report, {
3697
- name: "hub-server-processes",
3698
- status: "warning",
3699
- error: error.message,
3700
- });
3701
- warn(`hub/server.mjs 검사 실패: ${error.message}`);
3702
- issues++;
3703
- }
3704
-
3705
2716
  // 고아 node.exe 프로세스 정리 (Windows)
3706
2717
  section("Orphan Processes");
3707
2718
  if (process.platform === "win32") {
@@ -3874,56 +2885,6 @@ async function cmdDoctor(options = {}) {
3874
2885
  if (row.actualUrl) info(`actual ${row.actualUrl}`);
3875
2886
  }
3876
2887
 
3877
- // #144: --fix 모드에서 tfx-hub URL 불일치를 hub status 기준으로 자동 갱신.
3878
- // Project MCP (.mcp.json) 와 Codex/Claude/Gemini settings 모두 대상.
3879
- // Codex review P2: fix 성공 시 issues 집계에서 차감해야 doctor 결과가 ok 로 반영됨.
3880
- let autoFixedMismatches = 0;
3881
- if (fix && mismatchRows.some((r) => r.name === "tfx-hub")) {
3882
- try {
3883
- const hubUrl = mismatchRows.find(
3884
- (r) => r.name === "tfx-hub",
3885
- )?.expectedUrl;
3886
- if (hubUrl) {
3887
- const { syncHubMcpSettings, syncProjectMcpJson } = await import(
3888
- "../scripts/sync-hub-mcp-settings.mjs"
3889
- );
3890
- const settingsResult = await syncHubMcpSettings({
3891
- hubUrl,
3892
- logger: { log() {}, warn() {}, error() {} },
3893
- });
3894
- const projectResult = await syncProjectMcpJson({
3895
- hubUrl,
3896
- projectRoot: process.cwd(),
3897
- logger: { log() {}, warn() {}, error() {} },
3898
- });
3899
- const totalUpdated =
3900
- (settingsResult?.updated?.length || 0) +
3901
- (projectResult?.updated?.length || 0);
3902
- if (totalUpdated > 0) {
3903
- ok(`tfx-hub URL ${totalUpdated}개 파일 자동 갱신 (${hubUrl})`);
3904
- report.actions.push({
3905
- name: "sync-hub-url",
3906
- status: "applied",
3907
- files: [
3908
- ...(settingsResult?.updated || []),
3909
- ...(projectResult?.updated || []),
3910
- ],
3911
- });
3912
- // fix 성공 — mismatchRows 중 tfx-hub 엔트리는 해결된 것으로 집계
3913
- autoFixedMismatches = mismatchRows.filter(
3914
- (r) => r.name === "tfx-hub",
3915
- ).length;
3916
- } else {
3917
- info("tfx-hub URL 자동 갱신: 대상 파일 없음");
3918
- }
3919
- }
3920
- } catch (e) {
3921
- warn(
3922
- `tfx-hub URL 자동 갱신 실패: ${e?.message?.split(/\r?\n/)[0] || e}`,
3923
- );
3924
- }
3925
- }
3926
-
3927
2888
  for (const row of missingFileRows) {
3928
2889
  info(
3929
2890
  `${row.label}: ${row.name} 미배치 (${formatPathForDisplay(row.filePath)})`,
@@ -3946,15 +2907,33 @@ async function cmdDoctor(options = {}) {
3946
2907
  }
3947
2908
 
3948
2909
  issues += invalidConfigs.length;
3949
- issues += Math.max(0, mismatchRows.length - autoFixedMismatches);
2910
+ issues += mismatchRows.length;
3950
2911
  issues += stdioRows.length;
3951
2912
  }
3952
2913
  }
3953
2914
 
2915
+ // 제거된 허브가 cwd 에 만든 항목은 사용자 파일과 구분할 수 없어 자동으로 지우지 않는다.
2916
+ section("제거된 허브 흔적");
2917
+ {
2918
+ const leftovers = findProjectHubEntries();
2919
+ addDoctorCheck(report, {
2920
+ name: "legacy-hub-mcp-entries",
2921
+ status: leftovers.length > 0 ? "warning" : "ok",
2922
+ paths: leftovers,
2923
+ });
2924
+ if (leftovers.length === 0) {
2925
+ ok("현재 디렉터리에 tfx-hub MCP 항목 없음");
2926
+ } else {
2927
+ for (const file of leftovers) {
2928
+ warn(`tfx-hub MCP 항목: ${formatPathForDisplay(file)}`);
2929
+ }
2930
+ info("제거된 허브가 만든 항목입니다. 직접 지우세요.");
2931
+ issues += leftovers.length;
2932
+ }
2933
+ }
2934
+
3954
2935
  // ── Codex Config Health (BUG-H #132) ──
3955
- // _codex_config_swap 의 restore 가 Windows lock/ACL 로 실패하면
3956
- // ~/.codex/config.toml.pre-exec 가 남아 [mcp_servers.*] 섹션이 영구 손실된다.
3957
- // orphan backup 을 감지하고 --fix 로 자동 복원한다.
2936
+ // 이전 버전의 swap이 남긴 백업을 감지하고 --fix로 복원한다.
3958
2937
  section("Codex Config Health");
3959
2938
  {
3960
2939
  const codexConfig = join(CODEX_DIR, "config.toml");
@@ -4269,7 +3248,6 @@ async function cmdUpdate(args = []) {
4269
3248
  // 2. 설치 방식에 따라 업데이트
4270
3249
  const oldVer = PKG.version;
4271
3250
  let updated = false;
4272
- let stoppedHubInfo = null;
4273
3251
 
4274
3252
  try {
4275
3253
  switch (installMode) {
@@ -4286,10 +3264,6 @@ async function cmdUpdate(args = []) {
4286
3264
  break;
4287
3265
  }
4288
3266
  case "npm-global": {
4289
- stoppedHubInfo = stopHubForUpdate();
4290
- if (stoppedHubInfo?.pid) {
4291
- info(`실행 중 hub 정지 (PID ${stoppedHubInfo.pid})`);
4292
- }
4293
3267
  const npmCmd = isDev
4294
3268
  ? "npm install -g triflux@dev"
4295
3269
  : "npm install -g triflux@latest";
@@ -4355,9 +3329,6 @@ async function cmdUpdate(args = []) {
4355
3329
  return;
4356
3330
  }
4357
3331
  } catch (e) {
4358
- if (stoppedHubInfo && startHubAfterUpdate(stoppedHubInfo)) {
4359
- info("업데이트 실패 후 hub 재기동 시도");
4360
- }
4361
3332
  const stderr = e.stderr?.toString().trim();
4362
3333
  fail(
4363
3334
  `업데이트 실패: ${e.message}${stderr ? `\n ${stderr.split(/\r?\n/)[0]}` : ""}`,
@@ -4388,88 +3359,31 @@ async function cmdUpdate(args = []) {
4388
3359
  try {
4389
3360
  runUpdatedSetup({ packageRoot: updatedRoot });
4390
3361
  } catch (error) {
4391
- if (stoppedHubInfo) startHubAfterUpdate(stoppedHubInfo);
4392
3362
  throw createCliError(`업데이트 후 설정 동기화 실패: ${error.message}`, {
4393
3363
  exitCode: error.status || EXIT_ERROR,
4394
3364
  reason: "error",
4395
3365
  });
4396
3366
  }
4397
-
4398
- if (stoppedHubInfo) {
4399
- if (startHubAfterUpdate(stoppedHubInfo)) ok("hub 재기동 완료");
4400
- else warn("hub 재기동 실패 — `tfx hub start`로 수동 시작 필요");
4401
- }
4402
3367
  }
4403
3368
 
4404
3369
  console.log(`${GREEN}${BOLD}✓ 업데이트 완료${RESET}\n`);
4405
3370
  }
4406
3371
 
4407
- function readPackagedSkillMetadata(skillName) {
4408
- const skillPath = join(PKG_ROOT, "skills", skillName, "SKILL.md");
4409
- if (!existsSync(skillPath)) return null;
4410
- const metadata = { name: skillName, deprecated: false, supersededBy: null };
4411
- let inFrontmatter = false;
4412
- for (const line of readFileSync(skillPath, "utf8").split(/\r?\n/)) {
4413
- if (line.trim() === "---") {
4414
- if (!inFrontmatter) {
4415
- inFrontmatter = true;
4416
- continue;
4417
- }
4418
- break;
4419
- }
4420
- if (!inFrontmatter) continue;
4421
- const match = line.match(/^([A-Za-z0-9_-]+):\s*(.*)$/);
4422
- if (!match) continue;
4423
- const [, key, rawValue] = match;
4424
- const value = rawValue.trim().replace(/^["']|["']$/g, "");
4425
- if (key === "deprecated") metadata.deprecated = value === "true";
4426
- if (key === "superseded-by") metadata.supersededBy = value || null;
4427
- }
4428
- return metadata;
4429
- }
4430
-
4431
- function listDeprecatedPackagedSkillAliases(pluginSkills, installedSkills) {
4432
- if (!existsSync(pluginSkills)) return [];
4433
- return readdirSync(pluginSkills)
4434
- .sort()
4435
- .map((name) => readPackagedSkillMetadata(name))
4436
- .filter((metadata) => metadata?.deprecated)
4437
- .map((metadata) => ({
4438
- alias: metadata.name,
4439
- source: metadata.supersededBy || "deprecated",
4440
- installed: existsSync(join(installedSkills, metadata.name, "SKILL.md")),
4441
- deprecated: true,
4442
- }));
4443
- }
4444
-
4445
3372
  function cmdList(options = {}) {
4446
3373
  const { json = false } = options;
4447
3374
  const pluginSkills = join(PKG_ROOT, "skills");
4448
3375
  const installedSkills = join(CLAUDE_DIR, "skills");
4449
3376
  const packageSkills = [];
4450
3377
  const userSkills = [];
4451
- const skillAliases = listDeprecatedPackagedSkillAliases(
4452
- pluginSkills,
4453
- installedSkills,
4454
- );
4455
- const aliasNames = new Set([
4456
- ...SKILL_ALIASES.map(({ alias }) => alias),
4457
- ...skillAliases.map(({ alias }) => alias),
4458
- ]);
4459
3378
 
4460
3379
  if (existsSync(pluginSkills)) {
4461
3380
  for (const name of readdirSync(pluginSkills).sort()) {
4462
3381
  const src = join(pluginSkills, name, "SKILL.md");
4463
3382
  if (!existsSync(src)) continue;
4464
- const metadata = readPackagedSkillMetadata(name);
4465
3383
  const dst = join(installedSkills, name, "SKILL.md");
4466
3384
  packageSkills.push({
4467
3385
  name,
4468
3386
  installed: existsSync(dst),
4469
- ...(metadata?.deprecated ? { deprecated: true } : {}),
4470
- ...(metadata?.supersededBy
4471
- ? { superseded_by: metadata.supersededBy }
4472
- : {}),
4473
3387
  });
4474
3388
  }
4475
3389
  }
@@ -4479,7 +3393,7 @@ function cmdList(options = {}) {
4479
3393
  );
4480
3394
  if (existsSync(installedSkills)) {
4481
3395
  for (const name of readdirSync(installedSkills).sort()) {
4482
- if (pkgNames.has(name) || aliasNames.has(name)) continue;
3396
+ if (pkgNames.has(name)) continue;
4483
3397
  const skill = join(installedSkills, name, "SKILL.md");
4484
3398
  if (!existsSync(skill)) continue;
4485
3399
  userSkills.push(name);
@@ -4489,7 +3403,6 @@ function cmdList(options = {}) {
4489
3403
  if (json) {
4490
3404
  printJson({
4491
3405
  package_skills: packageSkills,
4492
- skill_aliases: skillAliases,
4493
3406
  user_skills: userSkills,
4494
3407
  install_path: installedSkills,
4495
3408
  });
@@ -4516,19 +3429,6 @@ function cmdList(options = {}) {
4516
3429
  }
4517
3430
  if (userSkills.length === 0) console.log(` ${GRAY}없음${RESET}`);
4518
3431
 
4519
- if (skillAliases.length > 0) {
4520
- section("호환 alias");
4521
- for (const entry of skillAliases) {
4522
- const icon = entry.installed
4523
- ? `${GREEN_BRIGHT}↳${RESET}`
4524
- : `${RED_BRIGHT}↳${RESET}`;
4525
- const status = entry.installed ? "" : ` ${GRAY}(미설치)${RESET}`;
4526
- console.log(
4527
- ` ${icon} ${BOLD}${entry.alias}${RESET} ${GRAY}→ ${entry.source}${RESET}${entry.deprecated ? ` ${YELLOW}(deprecated)${RESET}` : ""}${status}`,
4528
- );
4529
- }
4530
- }
4531
-
4532
3432
  console.log(`\n ${LINE}`);
4533
3433
  console.log(` ${GRAY}${installedSkills}${RESET}\n`);
4534
3434
  }
@@ -4554,188 +3454,6 @@ function cmdVersion(options = {}) {
4554
3454
  console.log("");
4555
3455
  }
4556
3456
 
4557
- function cmdHandoff(args = [], options = {}) {
4558
- if (args.some(isHelpArg)) {
4559
- printCommandHelp("handoff");
4560
- return;
4561
- }
4562
-
4563
- const { json = false } = options;
4564
- const parsed = {
4565
- target: "remote",
4566
- decisions: [],
4567
- decisionFile: null,
4568
- output: null,
4569
- cwd: process.cwd(),
4570
- };
4571
-
4572
- for (let index = 0; index < args.length; index += 1) {
4573
- const arg = args[index];
4574
- const next = args[index + 1];
4575
-
4576
- if (arg === "--target") {
4577
- if (!next || next.startsWith("-")) {
4578
- throw createCliError("--target 값이 필요합니다 (local|remote)", {
4579
- exitCode: EXIT_ARG_ERROR,
4580
- reason: "argError",
4581
- fix: "tfx handoff --target remote",
4582
- });
4583
- }
4584
- if (!["local", "remote"].includes(next)) {
4585
- throw createCliError(`지원하지 않는 --target 값: ${next}`, {
4586
- exitCode: EXIT_ARG_ERROR,
4587
- reason: "argError",
4588
- fix: "tfx handoff --target local|remote",
4589
- });
4590
- }
4591
- parsed.target = next;
4592
- index += 1;
4593
- continue;
4594
- }
4595
-
4596
- if (arg === "--decision") {
4597
- if (!next || next.startsWith("-")) {
4598
- throw createCliError("--decision 값이 필요합니다", {
4599
- exitCode: EXIT_ARG_ERROR,
4600
- reason: "argError",
4601
- fix: 'tfx handoff --decision "결정사항"',
4602
- });
4603
- }
4604
- parsed.decisions.push(next);
4605
- index += 1;
4606
- continue;
4607
- }
4608
-
4609
- if (arg === "--decision-file") {
4610
- if (!next || next.startsWith("-")) {
4611
- throw createCliError("--decision-file 경로가 필요합니다", {
4612
- exitCode: EXIT_ARG_ERROR,
4613
- reason: "argError",
4614
- fix: "tfx handoff --decision-file .omx/notepad.md",
4615
- });
4616
- }
4617
- parsed.decisionFile = resolve(next);
4618
- index += 1;
4619
- continue;
4620
- }
4621
-
4622
- if (arg === "--output" || arg === "--out") {
4623
- if (!next || next.startsWith("-")) {
4624
- throw createCliError(`${arg} 경로가 필요합니다`, {
4625
- exitCode: EXIT_ARG_ERROR,
4626
- reason: "argError",
4627
- fix: "tfx handoff --output .omx/handoff.md",
4628
- });
4629
- }
4630
- parsed.output = resolve(next);
4631
- index += 1;
4632
- continue;
4633
- }
4634
-
4635
- if (arg === "--cwd") {
4636
- if (!next || next.startsWith("-")) {
4637
- throw createCliError("--cwd 경로가 필요합니다", {
4638
- exitCode: EXIT_ARG_ERROR,
4639
- reason: "argError",
4640
- fix: "tfx handoff --cwd <project-path>",
4641
- });
4642
- }
4643
- parsed.cwd = resolve(next);
4644
- index += 1;
4645
- continue;
4646
- }
4647
-
4648
- throw createCliError(`알 수 없는 handoff 옵션: ${arg}`, {
4649
- exitCode: EXIT_ARG_ERROR,
4650
- reason: "argError",
4651
- fix: "tfx handoff --target remote --output .omx/handoff.md",
4652
- });
4653
- }
4654
-
4655
- const result = serializeHandoff({
4656
- target: parsed.target,
4657
- decisions: parsed.decisions,
4658
- decisionFile: parsed.decisionFile,
4659
- cwd: parsed.cwd,
4660
- });
4661
-
4662
- if (parsed.output) {
4663
- const outputDir = dirname(parsed.output);
4664
- if (!existsSync(outputDir)) mkdirSync(outputDir, { recursive: true });
4665
- writeFileSync(parsed.output, `${result.prompt}\n`, "utf8");
4666
- }
4667
-
4668
- if (json) {
4669
- printJson({
4670
- handoff: {
4671
- ...result,
4672
- ...(parsed.output ? { output: parsed.output } : {}),
4673
- },
4674
- });
4675
- return;
4676
- }
4677
-
4678
- console.log(result.prompt);
4679
- if (parsed.output) {
4680
- console.log(`\n${DIM}saved:${RESET} ${parsed.output}`);
4681
- }
4682
- }
4683
-
4684
- function cmdSchema(args = []) {
4685
- const bundle = loadDelegatorSchemaBundle();
4686
- const selector = String(args[0] || "").trim();
4687
- const toolEntry = Array.isArray(bundle["x-triflux-mcp-tools"])
4688
- ? bundle["x-triflux-mcp-tools"].find((tool) => tool.name === selector)
4689
- : null;
4690
-
4691
- if (isHelpArg(selector)) {
4692
- printCommandHelp("schema");
4693
- return;
4694
- }
4695
-
4696
- if (!selector) {
4697
- printJson({
4698
- $schema: bundle.$schema,
4699
- title: "Triflux CLI Schema Bundle",
4700
- global_options: [
4701
- {
4702
- name: "--json",
4703
- type: "boolean",
4704
- description: "지원 커맨드의 출력을 JSON으로 전환",
4705
- },
4706
- ],
4707
- commands: CLI_COMMAND_SCHEMAS,
4708
- hub_tools: bundle,
4709
- });
4710
- return;
4711
- }
4712
-
4713
- if (CLI_COMMAND_SCHEMAS[selector]) {
4714
- printJson({
4715
- command: selector,
4716
- ...CLI_COMMAND_SCHEMAS[selector],
4717
- });
4718
- return;
4719
- }
4720
-
4721
- if (toolEntry) {
4722
- printJson({
4723
- tool: toolEntry.name,
4724
- description: toolEntry.description,
4725
- pipeAction: toolEntry.pipeAction,
4726
- inputSchema: bundle.$defs?.[toolEntry.inputSchemaDef] || null,
4727
- outputSchema: bundle.$defs?.[toolEntry.outputSchemaDef] || null,
4728
- });
4729
- return;
4730
- }
4731
-
4732
- throw createCliError(`알 수 없는 schema 대상: ${selector}`, {
4733
- exitCode: EXIT_ARG_ERROR,
4734
- reason: "argError",
4735
- fix: "tfx schema 또는 tfx schema <command>를 실행해 사용 가능한 대상을 확인하세요.",
4736
- });
4737
- }
4738
-
4739
3457
  function cmdMcp(args = [], options = {}) {
4740
3458
  const { json = false } = options;
4741
3459
  const sub = String(args[0] || "list")
@@ -4970,82 +3688,32 @@ function cmdMcp(args = [], options = {}) {
4970
3688
  }
4971
3689
  }
4972
3690
 
4973
- function checkForUpdate() {
4974
- const cacheFile = join(CLAUDE_DIR, "cache", "triflux-update-check.json");
4975
- const cacheDir = dirname(cacheFile);
4976
-
4977
- // 캐시 확인 (1시간 이내면 캐시 사용)
4978
- try {
4979
- if (existsSync(cacheFile)) {
4980
- const cache = JSON.parse(readFileSync(cacheFile, "utf8"));
4981
- if (Date.now() - cache.timestamp < 3600000) {
4982
- return cache.latest !== PKG.version ? cache.latest : null;
4983
- }
4984
- }
4985
- } catch {}
4986
-
4987
- // npm registry 조회
4988
- try {
4989
- const result = execSync("npm view triflux version", {
4990
- encoding: "utf8",
4991
- timeout: 5000,
4992
- stdio: ["pipe", "pipe", "ignore"],
4993
- windowsHide: true,
4994
- }).trim();
4995
-
4996
- if (!existsSync(cacheDir)) mkdirSync(cacheDir, { recursive: true });
4997
- writeFileSync(
4998
- cacheFile,
4999
- JSON.stringify({ latest: result, timestamp: Date.now() }),
5000
- );
5001
-
5002
- return result !== PKG.version ? result : null;
5003
- } catch {
5004
- return null;
5005
- }
5006
- }
5007
-
5008
3691
  function cmdHelp() {
5009
- const latestVer = checkForUpdate();
5010
- const updateNotice = latestVer
5011
- ? `\n ${YELLOW}${BOLD}↑ v${latestVer} 사용 가능${RESET} ${GRAY}npm update -g triflux${RESET}\n`
5012
- : "";
5013
-
5014
3692
  console.log(`
5015
3693
  ${AMBER}${BOLD}⬡ triflux${RESET} ${DIM}v${PKG.version}${RESET}
5016
3694
  ${GRAY}CLI-first multi-model orchestrator for Claude Code${RESET}
5017
- ${updateNotice}
3695
+
5018
3696
  ${LINE}
5019
3697
 
5020
3698
  ${BOLD}Commands${RESET}
5021
3699
 
5022
3700
  ${WHITE_BRIGHT}tfx setup${RESET} ${GRAY}파일 동기화 + HUD 설정${RESET}
5023
3701
  ${DIM} --dry-run${RESET} ${GRAY}변경 예정 작업을 JSON으로 미리보기${RESET}
3702
+ ${DIM} --mods${RESET} ${GRAY}Claude Code mods 설치 (2.1.287 이상)${RESET}
5024
3703
  ${WHITE_BRIGHT}tfx doctor${RESET} ${GRAY}CLI 진단 + 이슈 확인${RESET}
5025
3704
  ${DIM} --fix${RESET} ${GRAY}진단 + 자동 수정${RESET}
5026
3705
  ${DIM} --reset${RESET} ${GRAY}캐시 전체 초기화${RESET}
5027
3706
  ${DIM} --json${RESET} ${GRAY}구조화된 진단 결과 JSON 출력${RESET}
5028
- ${WHITE_BRIGHT}tfx auto${RESET} ${GRAY}tfx-auto 라우팅 결정 미리보기 (--cli codex|antigravity|claude)${RESET}
5029
- ${WHITE_BRIGHT}tfx stealth-fetch${RESET} ${GRAY}cloakbrowser 기반 URL fetch (JSON stdout)${RESET}
5030
3707
  ${WHITE_BRIGHT}tfx mcp${RESET} ${GRAY}MCP registry 관리 (list/sync/add/remove)${RESET}
5031
3708
  ${WHITE_BRIGHT}tfx update${RESET} ${GRAY}최신 안정 버전으로 업데이트${RESET}
5032
3709
  ${DIM} --dev / dev${RESET} ${GRAY}dev 태그로 업데이트${RESET}
5033
3710
  ${WHITE_BRIGHT}tfx list${RESET} ${GRAY}설치된 스킬 목록${RESET}
5034
- ${WHITE_BRIGHT}tfx handoff${RESET} ${GRAY}현재 컨텍스트를 원격/로컬 핸드오프 프롬프트로 생성${RESET}
5035
- ${WHITE_BRIGHT}tfx schema${RESET} ${GRAY}CLI/Hub schema JSON 출력${RESET}
5036
- ${WHITE_BRIGHT}tfx hub${RESET} ${GRAY}MCP 메시지 버스 관리 (start/stop/status)${RESET}
5037
- ${WHITE_BRIGHT}tfx multi${RESET} ${GRAY}멀티-CLI 팀 모드 (tmux + Hub)${RESET}
5038
- ${WHITE_BRIGHT}tfx swarm${RESET} ${GRAY}PRD 기반 worktree 격리 병렬 실행 (run/plan/list)${RESET}
5039
- ${WHITE_BRIGHT}tfx synapse${RESET} ${GRAY}스웜 세션 registry 조회 / lease 관리${RESET}
5040
- ${WHITE_BRIGHT}tfx review${RESET} ${GRAY}Codex 기반 git diff review${RESET}
5041
- ${WHITE_BRIGHT}tfx why${RESET} ${GRAY}경로의 마지막 커밋 X-Intent 트레일러 추출${RESET}
5042
- ${WHITE_BRIGHT}tfx codex-team${RESET} ${GRAY}Codex 전용 팀 모드 (기본 lead/agents: codex)${RESET}
5043
- ${WHITE_BRIGHT}tfx notion-read${RESET} ${GRAY}Notion 페이지 → 마크다운 (Codex/Gemini MCP)${RESET}
3711
+ ${WHITE_BRIGHT}tfx multi${RESET} ${GRAY}멀티-CLI 팀 모드 (tmux)${RESET}
5044
3712
  ${WHITE_BRIGHT}tfx version${RESET} ${GRAY}버전 표시${RESET}
5045
3713
 
5046
3714
  ${BOLD}Skills${RESET} ${GRAY}(Claude Code 슬래시 커맨드)${RESET}
5047
3715
 
5048
- ${AMBER}/tfx-auto${RESET} ${GRAY}자동 분류 + 병렬 실행 (--cli codex|antigravity|claude, --parallel N|swarm)${RESET}
3716
+ ${AMBER}/tfx-auto${RESET} ${GRAY}자동 분류 + 병렬 실행 (--cli codex|antigravity|claude, --parallel N)${RESET}
5049
3717
  ${AMBER}/tfx-setup${RESET} ${GRAY}HUD 설정 + 진단${RESET}
5050
3718
  ${YELLOW}/tfx-doctor${RESET} ${GRAY}진단 + 수리 + 캐시 초기화${RESET}
5051
3719
 
@@ -5054,896 +3722,29 @@ ${updateNotice}
5054
3722
  `);
5055
3723
  }
5056
3724
 
5057
- async function cmdCodexTeam(args = []) {
5058
- const sub = String(args[0] || "").toLowerCase();
5059
- const passthrough = new Set([
5060
- "status",
5061
- "attach",
5062
- "stop",
5063
- "kill",
5064
- "send",
5065
- "list",
5066
- "help",
5067
- "--help",
5068
- "-h",
5069
- "tasks",
5070
- "task",
5071
- "focus",
5072
- "interrupt",
5073
- "control",
5074
- "debug",
5075
- ]);
5076
-
5077
- if (sub === "help" || sub === "--help" || sub === "-h") {
5078
- console.log(`
5079
- ${AMBER}${BOLD}⬡ tfx codex-team${RESET}
3725
+ // ── 메인 ──
5080
3726
 
5081
- ${WHITE_BRIGHT}tfx codex-team "작업"${RESET} ${GRAY}Codex 리드 + 워커 2개로 팀 시작${RESET}
5082
- ${WHITE_BRIGHT}tfx codex-team --layout 1xN "작업"${RESET} ${GRAY}(세로 분할 컬럼)${RESET}
5083
- ${WHITE_BRIGHT}tfx codex-team --layout Nx1 "작업"${RESET} ${GRAY}(가로 분할 스택)${RESET}
5084
- ${WHITE_BRIGHT}tfx codex-team status${RESET}
5085
- ${WHITE_BRIGHT}tfx codex-team debug --lines 30${RESET}
5086
- ${WHITE_BRIGHT}tfx codex-team send N "msg"${RESET}
3727
+ function readCliOptionValue(args, name) {
3728
+ const index = args.indexOf(name);
3729
+ if (index === -1) return null;
3730
+ const value = args[index + 1];
3731
+ if (!value || value.startsWith("--")) return null;
3732
+ return value;
3733
+ }
5087
3734
 
5088
- ${DIM}내부적으로 tfx multi을 호출하며, 시작 시 --lead codex --agents codex,codex를 기본 주입합니다.${RESET}
5089
- `);
5090
- return;
3735
+ function parsePositiveIntegerOption(args, name, fallback) {
3736
+ const value = readCliOptionValue(args, name);
3737
+ if (value == null) return fallback;
3738
+ const parsed = Number.parseInt(value, 10);
3739
+ if (!Number.isFinite(parsed) || parsed < 0) {
3740
+ throw createCliError(`${name} 값은 0 이상의 정수여야 합니다`, {
3741
+ exitCode: EXIT_ARG_ERROR,
3742
+ reason: "argError",
3743
+ fix: `${name} ${fallback}`,
3744
+ });
5091
3745
  }
5092
-
5093
- const hasAgents = args.includes("--agents");
5094
- const hasLead = args.includes("--lead");
5095
- const hasLayout = args.includes("--layout");
5096
- const isControl = passthrough.has(sub);
5097
- const normalizedArgs =
5098
- isControl && args.length ? [sub, ...args.slice(1)] : args;
5099
- const inject = [];
5100
- if (!isControl && !hasLead) inject.push("--lead", "codex");
5101
- if (!isControl && !hasAgents) inject.push("--agents", "codex,codex");
5102
- if (!isControl && !hasLayout) inject.push("--layout", "1xN");
5103
- const forwarded = isControl ? normalizedArgs : [...inject, ...args];
5104
-
5105
- const prevArgv = process.argv;
5106
- const prevProfile = process.env.TFX_TEAM_PROFILE;
5107
- process.env.TFX_TEAM_PROFILE = "codex-team";
5108
- const { pathToFileURL } = await import("node:url");
5109
- const { cmdTeam } = await import(
5110
- pathToFileURL(join(PKG_ROOT, "hub", "team", "cli", "index.mjs")).href
5111
- );
5112
- process.argv = [prevArgv[0], prevArgv[1], "team", ...forwarded];
5113
- try {
5114
- await cmdTeam();
5115
- } finally {
5116
- process.argv = prevArgv;
5117
- if (typeof prevProfile === "string")
5118
- process.env.TFX_TEAM_PROFILE = prevProfile;
5119
- else delete process.env.TFX_TEAM_PROFILE;
5120
- }
5121
- }
5122
-
5123
- // ── Hub preflight 체크 (multi/auto 실행 전) ──
5124
-
5125
- async function checkHubRunning() {
5126
- // preflight 캐시 먼저 확인 — 히트 시 fetch 스킵
5127
- try {
5128
- const cacheFile = join(homedir(), ".claude", "cache", "tfx-preflight.json");
5129
- const cached = JSON.parse(readFileSync(cacheFile, "utf8"));
5130
- if (Date.now() - cached.timestamp < 3_600_000 && cached.hub?.ok)
5131
- return true;
5132
- } catch {}
5133
- const port = Number(process.env.TFX_HUB_PORT || "27888");
5134
- try {
5135
- const res = await fetch(`http://127.0.0.1:${port}/status`, {
5136
- signal: AbortSignal.timeout(2000),
5137
- });
5138
- if (res.ok) return true;
5139
- } catch {}
5140
- console.log("");
5141
- warn(`${AMBER}tfx-hub${RESET}가 실행되고 있지 않습니다.`);
5142
- info(
5143
- `Hub 없이 실행하면 Claude 네이티브 에이전트로 폴백되어 토큰이 소비됩니다.`,
5144
- );
5145
- info(`Codex(무료) 위임을 활용하려면 먼저 Hub를 시작하세요:\n`);
5146
- console.log(` ${WHITE_BRIGHT}tfx hub start${RESET}\n`);
5147
- return false;
5148
- }
5149
-
5150
- // ── hub 서브커맨드 ──
5151
-
5152
- const HUB_PID_DIR = join(homedir(), ".claude", "cache", "tfx-hub");
5153
- const HUB_PID_FILE = join(HUB_PID_DIR, "hub.pid");
5154
-
5155
- function sleepMs(ms) {
5156
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
5157
- }
5158
-
5159
- function stopHubForUpdate() {
5160
- if (!existsSync(HUB_PID_FILE)) return null;
5161
- let info = null;
5162
- try {
5163
- info = JSON.parse(readFileSync(HUB_PID_FILE, "utf8"));
5164
- process.kill(info.pid, 0);
5165
- } catch {
5166
- try {
5167
- unlinkSync(HUB_PID_FILE);
5168
- } catch {}
5169
- return null;
5170
- }
5171
-
5172
- try {
5173
- if (process.platform === "win32") {
5174
- execFileSync("taskkill", ["/PID", String(info.pid), "/T", "/F"], {
5175
- stdio: ["pipe", "pipe", "ignore"],
5176
- timeout: 10000,
5177
- windowsHide: true,
5178
- });
5179
- } else {
5180
- process.kill(info.pid, "SIGTERM");
5181
- }
5182
- } catch {
5183
- try {
5184
- process.kill(info.pid, "SIGKILL");
5185
- } catch {}
5186
- }
5187
-
5188
- // Windows에서 better-sqlite3.node 파일 핸들 해제 대기
5189
- // taskkill 후 프로세스 종료 + 파일 핸들 해제까지 최대 5초
5190
- const sqliteNode = join(
5191
- PKG_ROOT,
5192
- "node_modules",
5193
- "better-sqlite3",
5194
- "build",
5195
- "Release",
5196
- "better_sqlite3.node",
5197
- );
5198
- for (let i = 0; i < 10; i++) {
5199
- sleepMs(500);
5200
- try {
5201
- process.kill(info.pid, 0);
5202
- } catch {
5203
- break;
5204
- }
5205
- }
5206
- // 파일 잠금 해제 확인 (Windows EBUSY 방지)
5207
- if (existsSync(sqliteNode)) {
5208
- for (let i = 0; i < 6; i++) {
5209
- try {
5210
- const fd = openSync(sqliteNode, "r");
5211
- closeSync(fd);
5212
- break;
5213
- } catch {
5214
- sleepMs(500);
5215
- }
5216
- }
5217
- }
5218
- try {
5219
- unlinkSync(HUB_PID_FILE);
5220
- } catch {}
5221
- return info;
5222
- }
5223
-
5224
- function openHubLogFd() {
5225
- try {
5226
- const logDir = join(homedir(), ".claude", "cache", "tfx-hub");
5227
- mkdirSync(logDir, { recursive: true });
5228
- return openSync(join(logDir, "hub.log"), "a");
5229
- } catch {
5230
- return undefined;
5231
- }
5232
- }
5233
-
5234
- function startHubAfterUpdate(info) {
5235
- if (!info) return false;
5236
- const serverPath = join(PKG_ROOT, "hub", "server.mjs");
5237
- if (!existsSync(serverPath)) return false;
5238
- const port =
5239
- Number(info?.port) > 0
5240
- ? String(info.port)
5241
- : String(process.env.TFX_HUB_PORT || "27888");
5242
-
5243
- try {
5244
- const logFd = openHubLogFd();
5245
- const child = spawn(process.execPath, [serverPath], {
5246
- env: { ...process.env, TFX_HUB_PORT: port },
5247
- stdio: ["ignore", logFd ?? "ignore", logFd ?? "ignore"],
5248
- detached: true,
5249
- windowsHide: true,
5250
- });
5251
- child.unref();
5252
- if (logFd !== undefined) {
5253
- try {
5254
- closeSync(logFd);
5255
- } catch {}
5256
- }
5257
- return true;
5258
- } catch {
5259
- return false;
5260
- }
5261
- }
5262
-
5263
- // 설치된 CLI에 tfx-hub MCP 서버 자동 등록 (1회 설정, 이후 재실행 불필요)
5264
- function autoRegisterMcp(mcpUrl, { codexEnabled = false } = {}) {
5265
- section("MCP 자동 등록");
5266
-
5267
- // Codex — config.json에 기본 disabled 엔트리로 등록.
5268
- // Hub startup must keep the MCP config fresh even on CI/dev machines where
5269
- // the Codex CLI binary itself is not installed.
5270
- try {
5271
- const result = ensureCodexHubServerConfig({
5272
- mcpUrl,
5273
- createIfMissing: true,
5274
- enabled: codexEnabled,
5275
- });
5276
- if (!result.ok) throw new Error(result.reason || "unknown");
5277
- const suffix = which("codex") ? "" : " (CLI 미설치)";
5278
- if (result.changed) {
5279
- ok(
5280
- `Codex: config.json에 등록 완료 (${codexEnabled ? "enabled" : "기본 disabled"})${suffix}`,
5281
- );
5282
- } else {
5283
- ok(
5284
- `Codex: 이미 등록됨 (${codexEnabled ? "enabled" : "기본 disabled"})${suffix}`,
5285
- );
5286
- }
5287
- } catch (e) {
5288
- warn(`Codex 등록 실패: ${e.message}`);
5289
- }
5290
-
5291
- // Gemini — settings.json 직접 수정
5292
- if (which("gemini")) {
5293
- try {
5294
- const geminiDir = join(homedir(), ".gemini");
5295
- const settingsFile = join(geminiDir, "settings.json");
5296
- let settings = {};
5297
- if (existsSync(settingsFile))
5298
- settings = JSON.parse(readFileSync(settingsFile, "utf8"));
5299
- if (!settings.mcpServers) settings.mcpServers = {};
5300
- const current = settings.mcpServers["tfx-hub"];
5301
- if (!current || current.url !== mcpUrl) {
5302
- settings.mcpServers["tfx-hub"] = {
5303
- ...(current && typeof current === "object" ? current : {}),
5304
- url: mcpUrl,
5305
- };
5306
- if (!existsSync(geminiDir)) mkdirSync(geminiDir, { recursive: true });
5307
- writeFileSync(settingsFile, JSON.stringify(settings, null, 2) + "\n");
5308
- ok(
5309
- current
5310
- ? "Gemini: settings.json URL 갱신 완료"
5311
- : "Gemini: settings.json에 등록 완료",
5312
- );
5313
- } else {
5314
- ok("Gemini: 이미 등록됨");
5315
- }
5316
- } catch (e) {
5317
- warn(`Gemini 등록 실패: ${e.message}`);
5318
- }
5319
- } else {
5320
- info("Gemini: 미설치 (건너뜀)");
5321
- }
5322
-
5323
- // Claude — .claude/mcp.json에 등록 (Claude Code 공식 경로)
5324
- try {
5325
- const claudeDir = join(process.cwd(), ".claude");
5326
- if (!existsSync(claudeDir)) mkdirSync(claudeDir, { recursive: true });
5327
- const mcpJsonPath = join(claudeDir, "mcp.json");
5328
- let mcpJson = {};
5329
- if (existsSync(mcpJsonPath))
5330
- mcpJson = JSON.parse(readFileSync(mcpJsonPath, "utf8"));
5331
- if (!mcpJson.mcpServers) mcpJson.mcpServers = {};
5332
- const current = mcpJson.mcpServers["tfx-hub"];
5333
- if (!current || current.type !== "http" || current.url !== mcpUrl) {
5334
- mcpJson.mcpServers["tfx-hub"] = {
5335
- ...(current && typeof current === "object" ? current : {}),
5336
- type: "http",
5337
- url: mcpUrl,
5338
- };
5339
- writeFileSync(mcpJsonPath, JSON.stringify(mcpJson, null, 2) + "\n");
5340
- ok(
5341
- current
5342
- ? "Claude: .claude/mcp.json URL/type 갱신 완료"
5343
- : "Claude: .claude/mcp.json에 등록 완료",
5344
- );
5345
- } else {
5346
- ok("Claude: 이미 등록됨");
5347
- }
5348
- } catch (e) {
5349
- warn(`Claude 등록 실패: ${e.message}`);
5350
- }
5351
- }
5352
-
5353
- async function cmdHub(args = [], options = {}) {
5354
- const { json = false } = options;
5355
- const sub = args[0] || "status";
5356
- const defaultPortRaw = Number(process.env.TFX_HUB_PORT || "27888");
5357
- const probePort =
5358
- Number.isFinite(defaultPortRaw) && defaultPortRaw > 0
5359
- ? defaultPortRaw
5360
- : 27888;
5361
- const formatHostForUrl = (host) => (host.includes(":") ? `[${host}]` : host);
5362
- const probeHubStatus = async (
5363
- host = "127.0.0.1",
5364
- port = probePort,
5365
- timeoutMs = 3000,
5366
- ) => {
5367
- try {
5368
- const res = await fetch(
5369
- `http://${formatHostForUrl(host)}:${port}/status`,
5370
- {
5371
- signal: AbortSignal.timeout(timeoutMs),
5372
- },
5373
- );
5374
- if (!res.ok) return null;
5375
- const data = await res.json();
5376
- return data?.hub ? data : null;
5377
- } catch {
5378
- return null;
5379
- }
5380
- };
5381
- const recoverPidFile = (statusData, defaultHost = "127.0.0.1") => {
5382
- const pid = Number(statusData?.pid);
5383
- const port = Number(statusData?.port) || probePort;
5384
- if (!Number.isFinite(pid) || pid <= 0) return;
5385
- try {
5386
- mkdirSync(HUB_PID_DIR, { recursive: true });
5387
- writeFileSync(
5388
- HUB_PID_FILE,
5389
- JSON.stringify({
5390
- pid,
5391
- port,
5392
- host: defaultHost,
5393
- url: `http://${formatHostForUrl(defaultHost)}:${port}/mcp`,
5394
- started: Date.now(),
5395
- }),
5396
- );
5397
- } catch {}
5398
- };
5399
- const emitHubStatus = (payload) => {
5400
- if (!json) return false;
5401
- printJson(payload);
5402
- return true;
5403
- };
5404
-
5405
- switch (sub) {
5406
- case "start": {
5407
- // 이미 실행 중인지 확인
5408
- if (existsSync(HUB_PID_FILE)) {
5409
- try {
5410
- const info = JSON.parse(readFileSync(HUB_PID_FILE, "utf8"));
5411
- process.kill(info.pid, 0); // 프로세스 존재 확인
5412
- const host =
5413
- typeof info.host === "string" && info.host.trim()
5414
- ? info.host.trim()
5415
- : "127.0.0.1";
5416
- const port = Number(info.port) || probePort;
5417
- const probed = await probeHubStatus(host, port, 1500);
5418
- if (probed?.hub) {
5419
- const url = `http://${formatHostForUrl(host)}:${probed.port || port}/mcp`;
5420
- recoverPidFile(probed, host);
5421
- autoRegisterMcp(url, { codexEnabled: true });
5422
- console.log(
5423
- `\n ${YELLOW}⚠${RESET} hub 이미 실행 중 (PID ${probed.pid || info.pid}, ${url})\n`,
5424
- );
5425
- return;
5426
- }
5427
- warn(
5428
- `stale hub PID 파일 감지: PID ${info.pid}는 살아있지만 hub status 응답이 없음. PID 파일을 정리합니다.`,
5429
- );
5430
- unlinkSync(HUB_PID_FILE);
5431
- } catch {
5432
- // PID 파일 있지만 프로세스 없음 — 정리
5433
- try {
5434
- unlinkSync(HUB_PID_FILE);
5435
- } catch {}
5436
- }
5437
- }
5438
-
5439
- const portArg = args.indexOf("--port");
5440
- const port = portArg !== -1 ? args[portArg + 1] : String(probePort);
5441
- const serverPath = join(PKG_ROOT, "hub", "server.mjs");
5442
-
5443
- if (!existsSync(serverPath)) {
5444
- throw createCliError("hub/server.mjs 없음 — hub 모듈이 설치되지 않음", {
5445
- exitCode: EXIT_HUB_ERROR,
5446
- reason: "hubError",
5447
- fix: "hub 모듈이 포함된 triflux 설치본인지 확인한 뒤 다시 실행하세요.",
5448
- });
5449
- }
5450
-
5451
- // Issue #102 + hub-detach fix: spawn stdout/stderr 를 두 채널로 redirect.
5452
- // - startupErrPath (tmp): 3초 안의 startup 실패 진단 (성공 시 cleanup)
5453
- // - hub.log (cache): runtime stdout/stderr 영구 보존 (crash 추적)
5454
- // detached spawn 은 pipe 유지가 까다로우니 fd 리다이렉트로 접근.
5455
- const { openSync: _openSync, closeSync: _closeSync } = await import(
5456
- "node:fs"
5457
- );
5458
- const { tmpdir: _tmpdir } = await import("node:os");
5459
- const startupErrPath = join(
5460
- _tmpdir(),
5461
- `tfx-hub-start-${Date.now()}-${process.pid}.err`,
5462
- );
5463
- let errFd;
5464
- try {
5465
- errFd = _openSync(startupErrPath, "w");
5466
- } catch {
5467
- errFd = undefined;
5468
- }
5469
- const logFd = openHubLogFd();
5470
-
5471
- const child = spawn(process.execPath, [serverPath], {
5472
- env: { ...process.env, TFX_HUB_PORT: port },
5473
- stdio: ["ignore", logFd ?? "ignore", errFd ?? logFd ?? "ignore"],
5474
- detached: true,
5475
- windowsHide: true,
5476
- });
5477
- child.unref();
5478
- if (errFd !== undefined) {
5479
- try {
5480
- _closeSync(errFd);
5481
- } catch {}
5482
- }
5483
- if (logFd !== undefined) {
5484
- try {
5485
- _closeSync(logFd);
5486
- } catch {}
5487
- }
5488
-
5489
- // PID 파일 확인 (최대 3초 대기, 100ms 폴링)
5490
- let started = false;
5491
- const deadline = Date.now() + 3000;
5492
- while (Date.now() < deadline) {
5493
- if (existsSync(HUB_PID_FILE)) {
5494
- started = true;
5495
- break;
5496
- }
5497
- await new Promise((r) => setTimeout(r, 100));
5498
- }
5499
-
5500
- if (started) {
5501
- const hubInfo = JSON.parse(readFileSync(HUB_PID_FILE, "utf8"));
5502
- console.log(`\n ${GREEN_BRIGHT}✓${RESET} ${BOLD}tfx-hub 시작${RESET}`);
5503
- console.log(` URL: ${AMBER}${hubInfo.url}${RESET}`);
5504
- console.log(` PID: ${hubInfo.pid}`);
5505
- console.log(
5506
- ` DB: ${DIM}${getPipelineStateDbPath(PKG_ROOT)}${RESET}`,
5507
- );
5508
- console.log("");
5509
- autoRegisterMcp(hubInfo.url, { codexEnabled: true });
5510
- console.log("");
5511
- // 성공했으면 임시 stderr 파일 정리
5512
- try {
5513
- unlinkSync(startupErrPath);
5514
- } catch {}
5515
- } else {
5516
- // Issue #102: 캡처된 stderr 에서 root cause 추출
5517
- let rootCause = "";
5518
- try {
5519
- rootCause = readFileSync(startupErrPath, "utf8").trim();
5520
- } catch {}
5521
-
5522
- console.log(`\n ${YELLOW}⚠${RESET} 백그라운드 시작 실패`);
5523
-
5524
- if (rootCause) {
5525
- // 가장 유용한 에러 라인 강조 (ERR_*, Error:, throw)
5526
- const highlight = rootCause
5527
- .split(/\r?\n/)
5528
- .find((line) => /ERR_[A-Z_]+|^Error:|cannot find/i.test(line));
5529
- if (highlight) {
5530
- console.log(` ${RED}▸ ${highlight.trim()}${RESET}`);
5531
- }
5532
- console.log(`\n ${DIM}전체 로그: ${startupErrPath}${RESET}`);
5533
- // 원인별 실전 힌트
5534
- if (/Cannot find package/i.test(rootCause)) {
5535
- console.log(
5536
- ` ${DIM}힌트: \`cd ${PKG_ROOT} && npm install\` 로 의존성 복구 (특히 \`npm link\` 환경).${RESET}`,
5537
- );
5538
- } else if (/EADDRINUSE/i.test(rootCause)) {
5539
- console.log(
5540
- ` ${DIM}힌트: 포트 ${port} 이 이미 사용 중. \`tfx hub stop\` 후 재시도.${RESET}`,
5541
- );
5542
- }
5543
- } else {
5544
- console.log(
5545
- ` ${DIM}stderr 캡처 실패 — 아래 명령으로 포그라운드 실행해 원인 확인:${RESET}`,
5546
- );
5547
- }
5548
-
5549
- console.log(
5550
- `\n ${DIM}포그라운드 실행: TFX_HUB_PORT=${port} node ${serverPath}${RESET}\n`,
5551
- );
5552
- }
5553
- break;
5554
- }
5555
-
5556
- case "stop": {
5557
- if (!existsSync(HUB_PID_FILE)) {
5558
- const probed =
5559
- (await probeHubStatus("127.0.0.1", probePort, 1500)) ||
5560
- (probePort === 27888
5561
- ? null
5562
- : await probeHubStatus("127.0.0.1", 27888, 1500));
5563
- if (probed && Number.isFinite(Number(probed.pid))) {
5564
- try {
5565
- process.kill(Number(probed.pid), "SIGTERM");
5566
- console.log(
5567
- `\n ${GREEN_BRIGHT}✓${RESET} hub 종료됨 (PID ${probed.pid})${DIM} (probe)${RESET}\n`,
5568
- );
5569
- return;
5570
- } catch {}
5571
- }
5572
- console.log(`\n ${DIM}hub 미실행${RESET}\n`);
5573
- return;
5574
- }
5575
- try {
5576
- const info = JSON.parse(readFileSync(HUB_PID_FILE, "utf8"));
5577
- process.kill(info.pid, "SIGTERM");
5578
- try {
5579
- unlinkSync(HUB_PID_FILE);
5580
- } catch {}
5581
- console.log(
5582
- `\n ${GREEN_BRIGHT}✓${RESET} hub 종료됨 (PID ${info.pid})\n`,
5583
- );
5584
- } catch (_e) {
5585
- try {
5586
- unlinkSync(HUB_PID_FILE);
5587
- } catch {}
5588
- console.log(`\n ${DIM}hub 프로세스 없음 — PID 파일 정리됨${RESET}\n`);
5589
- }
5590
- break;
5591
- }
5592
-
5593
- case "status": {
5594
- if (!existsSync(HUB_PID_FILE)) {
5595
- const probed = await probeHubStatus();
5596
- if (!probed) {
5597
- const fallback =
5598
- probePort === 27888
5599
- ? null
5600
- : await probeHubStatus("127.0.0.1", 27888, 1500);
5601
- if (fallback) {
5602
- recoverPidFile(fallback, "127.0.0.1");
5603
- if (
5604
- emitHubStatus({
5605
- status: "online",
5606
- source: "default-port-probe",
5607
- url: `http://127.0.0.1:${fallback.port || 27888}/mcp`,
5608
- pid: fallback.pid,
5609
- state: fallback.hub?.state || null,
5610
- sessions: fallback.sessions,
5611
- })
5612
- )
5613
- return;
5614
- console.log(
5615
- `\n ${AMBER}${BOLD}⬡ tfx-hub${RESET} ${GREEN_BRIGHT}online${RESET} ${DIM}(default port probe 성공)${RESET}`,
5616
- );
5617
- console.log(
5618
- ` URL: http://127.0.0.1:${fallback.port || 27888}/mcp`,
5619
- );
5620
- if (fallback.pid !== undefined)
5621
- console.log(` PID: ${fallback.pid}`);
5622
- if (fallback.hub?.state)
5623
- console.log(` State: ${fallback.hub.state}`);
5624
- if (fallback.sessions !== undefined)
5625
- console.log(` Sessions: ${fallback.sessions}`);
5626
- console.log("");
5627
- return;
5628
- }
5629
- if (
5630
- emitHubStatus({
5631
- status: "offline",
5632
- source: "probe",
5633
- url: null,
5634
- pid: null,
5635
- state: null,
5636
- sessions: 0,
5637
- })
5638
- )
5639
- return;
5640
- console.log(
5641
- `\n ${AMBER}${BOLD}⬡ tfx-hub${RESET} ${RED}offline${RESET}\n`,
5642
- );
5643
- return;
5644
- }
5645
- recoverPidFile(probed, "127.0.0.1");
5646
- if (
5647
- emitHubStatus({
5648
- status: "online",
5649
- source: "probe",
5650
- url: `http://127.0.0.1:${probed.port || probePort}/mcp`,
5651
- pid: probed.pid,
5652
- state: probed.hub?.state || null,
5653
- sessions: probed.sessions,
5654
- })
5655
- )
5656
- return;
5657
- console.log(
5658
- `\n ${AMBER}${BOLD}⬡ tfx-hub${RESET} ${GREEN_BRIGHT}online${RESET} ${DIM}(pid file 없음 / probe 성공)${RESET}`,
5659
- );
5660
- console.log(
5661
- ` URL: http://127.0.0.1:${probed.port || probePort}/mcp`,
5662
- );
5663
- if (probed.pid !== undefined) console.log(` PID: ${probed.pid}`);
5664
- if (probed.hub?.state) console.log(` State: ${probed.hub.state}`);
5665
- if (probed.sessions !== undefined)
5666
- console.log(` Sessions: ${probed.sessions}`);
5667
- console.log("");
5668
- return;
5669
- }
5670
- try {
5671
- const info = JSON.parse(readFileSync(HUB_PID_FILE, "utf8"));
5672
- process.kill(info.pid, 0); // 생존 확인
5673
- const uptime = Date.now() - info.started;
5674
- const uptimeStr =
5675
- uptime < 60000
5676
- ? `${Math.round(uptime / 1000)}초`
5677
- : uptime < 3600000
5678
- ? `${Math.round(uptime / 60000)}분`
5679
- : `${Math.round(uptime / 3600000)}시간`;
5680
-
5681
- let data = null;
5682
- try {
5683
- const host = typeof info.host === "string" ? info.host : "127.0.0.1";
5684
- const port = Number(info.port) || probePort;
5685
- data = await probeHubStatus(host, port, 3000);
5686
- } catch {}
5687
-
5688
- if (
5689
- emitHubStatus({
5690
- status: "online",
5691
- source: "pid-file",
5692
- url: info.url,
5693
- pid: info.pid,
5694
- uptime_ms: uptime,
5695
- state: data?.hub?.state || null,
5696
- sessions: data?.sessions,
5697
- })
5698
- )
5699
- return;
5700
- console.log(
5701
- `\n ${AMBER}${BOLD}⬡ tfx-hub${RESET} ${GREEN_BRIGHT}online${RESET}`,
5702
- );
5703
- console.log(` URL: ${info.url}`);
5704
- console.log(` PID: ${info.pid}`);
5705
- console.log(` Uptime: ${uptimeStr}`);
5706
- if (data?.hub) {
5707
- console.log(` State: ${data.hub.state}`);
5708
- }
5709
- if (data?.sessions !== undefined) {
5710
- console.log(` Sessions: ${data.sessions}`);
5711
- }
5712
- console.log("");
5713
- } catch {
5714
- try {
5715
- unlinkSync(HUB_PID_FILE);
5716
- } catch {}
5717
- const probed = await probeHubStatus();
5718
- if (!probed) {
5719
- if (
5720
- emitHubStatus({
5721
- status: "offline",
5722
- source: "stale-pid",
5723
- url: null,
5724
- pid: null,
5725
- state: null,
5726
- sessions: 0,
5727
- })
5728
- )
5729
- break;
5730
- console.log(
5731
- `\n ${AMBER}${BOLD}⬡ tfx-hub${RESET} ${RED}offline${RESET} ${DIM}(stale PID 정리됨)${RESET}\n`,
5732
- );
5733
- break;
5734
- }
5735
- recoverPidFile(probed, "127.0.0.1");
5736
- if (
5737
- emitHubStatus({
5738
- status: "online",
5739
- source: "stale-pid-probe",
5740
- url: `http://127.0.0.1:${probed.port || probePort}/mcp`,
5741
- pid: probed.pid,
5742
- state: probed.hub?.state || null,
5743
- sessions: probed.sessions,
5744
- })
5745
- )
5746
- break;
5747
- console.log(
5748
- `\n ${AMBER}${BOLD}⬡ tfx-hub${RESET} ${GREEN_BRIGHT}online${RESET} ${DIM}(stale PID 정리 후 probe 성공)${RESET}`,
5749
- );
5750
- console.log(
5751
- ` URL: http://127.0.0.1:${probed.port || probePort}/mcp`,
5752
- );
5753
- if (probed.pid !== undefined) console.log(` PID: ${probed.pid}`);
5754
- if (probed.hub?.state) console.log(` State: ${probed.hub.state}`);
5755
- if (probed.sessions !== undefined)
5756
- console.log(` Sessions: ${probed.sessions}`);
5757
- console.log("");
5758
- }
5759
- break;
5760
- }
5761
-
5762
- case "ensure": {
5763
- // 사일런트 idempotent 보장 — 스킬 환경 프로브용.
5764
- // Hub 살아있으면 즉시 종료, 죽어있으면 자동 시작 + ready 대기.
5765
- const portArg = args.indexOf("--port");
5766
- const ensurePort =
5767
- portArg !== -1
5768
- ? args[portArg + 1]
5769
- : process.env.TFX_HUB_PORT || "27888";
5770
-
5771
- // 1. 이미 healthy?
5772
- const ensureProbed = await probeHubStatus(
5773
- "127.0.0.1",
5774
- Number(ensurePort),
5775
- 1500,
5776
- );
5777
- if (ensureProbed?.hub?.state === "healthy") {
5778
- if (json)
5779
- printJson({
5780
- status: "ok",
5781
- pid: ensureProbed.pid,
5782
- port: Number(ensurePort),
5783
- });
5784
- else process.stdout.write("hub: ok\n");
5785
- return;
5786
- }
5787
-
5788
- // 2. PID 파일 있는데 프로세스 죽었으면 정리
5789
- if (existsSync(HUB_PID_FILE)) {
5790
- try {
5791
- const staleInfo = JSON.parse(readFileSync(HUB_PID_FILE, "utf8"));
5792
- process.kill(staleInfo.pid, 0);
5793
- // 프로세스 살아있지만 healthy가 아님 — 잠시 더 대기
5794
- const retryDeadline = Date.now() + 3000;
5795
- while (Date.now() < retryDeadline) {
5796
- await new Promise((r) => setTimeout(r, 250));
5797
- const retry = await probeHubStatus(
5798
- "127.0.0.1",
5799
- Number(ensurePort),
5800
- 1000,
5801
- );
5802
- if (retry?.hub?.state === "healthy") {
5803
- if (json)
5804
- printJson({
5805
- status: "ok",
5806
- pid: retry.pid,
5807
- port: Number(ensurePort),
5808
- });
5809
- else process.stdout.write("hub: ok\n");
5810
- return;
5811
- }
5812
- }
5813
- } catch {
5814
- try {
5815
- unlinkSync(HUB_PID_FILE);
5816
- } catch {}
5817
- }
5818
- }
5819
-
5820
- // 3. 시작
5821
- const serverPath = join(PKG_ROOT, "hub", "server.mjs");
5822
- if (!existsSync(serverPath)) {
5823
- if (json) printJson({ status: "error", reason: "server_missing" });
5824
- else process.stderr.write("hub: server.mjs not found\n");
5825
- process.exitCode = 1;
5826
- return;
5827
- }
5828
-
5829
- if (process.platform === "win32") {
5830
- const child = spawn(
5831
- "cmd.exe",
5832
- ["/c", "start", "/b", "", process.execPath, serverPath],
5833
- {
5834
- env: { ...process.env, TFX_HUB_PORT: String(ensurePort) },
5835
- stdio: "ignore",
5836
- windowsHide: true,
5837
- },
5838
- );
5839
- child.unref();
5840
- } else {
5841
- const child = spawn(process.execPath, [serverPath], {
5842
- env: { ...process.env, TFX_HUB_PORT: String(ensurePort) },
5843
- detached: true,
5844
- stdio: "ignore",
5845
- });
5846
- child.unref();
5847
- }
5848
-
5849
- // 4. ready 대기 (최대 5초)
5850
- const readyDeadline = Date.now() + 5000;
5851
- while (Date.now() < readyDeadline) {
5852
- await new Promise((r) => setTimeout(r, 250));
5853
- if (existsSync(HUB_PID_FILE)) {
5854
- const readyProbe = await probeHubStatus(
5855
- "127.0.0.1",
5856
- Number(ensurePort),
5857
- 1000,
5858
- );
5859
- if (readyProbe?.hub?.state === "healthy") {
5860
- if (json)
5861
- printJson({
5862
- status: "ok",
5863
- pid: readyProbe.pid,
5864
- port: Number(ensurePort),
5865
- started: true,
5866
- });
5867
- else process.stdout.write("hub: started\n");
5868
- return;
5869
- }
5870
- }
5871
- }
5872
-
5873
- // 5. 타임아웃이지만 프로세스는 기동 중일 수 있음
5874
- if (json) printJson({ status: "starting", port: Number(ensurePort) });
5875
- else process.stdout.write("hub: starting\n");
5876
- break;
5877
- }
5878
-
5879
- default:
5880
- console.log(`\n ${AMBER}${BOLD}⬡ tfx-hub${RESET}\n`);
5881
- console.log(
5882
- ` ${WHITE_BRIGHT}tfx hub start${RESET} ${GRAY}허브 데몬 시작${RESET}`,
5883
- );
5884
- console.log(
5885
- ` ${DIM} --port N${RESET} ${GRAY}포트 지정 (기본 27888)${RESET}`,
5886
- );
5887
- console.log(
5888
- ` ${WHITE_BRIGHT}tfx hub stop${RESET} ${GRAY}허브 중지${RESET}`,
5889
- );
5890
- console.log(
5891
- ` ${WHITE_BRIGHT}tfx hub status${RESET} ${GRAY}상태 확인${RESET}`,
5892
- );
5893
- console.log(
5894
- ` ${WHITE_BRIGHT}tfx hub ensure${RESET} ${GRAY}헬스체크 + 자동 시작 (스킬 프로브용)${RESET}\n`,
5895
- );
5896
- }
5897
- }
5898
-
5899
- // ── 메인 ──
5900
-
5901
- function readCliOptionValue(args, name) {
5902
- const index = args.indexOf(name);
5903
- if (index === -1) return null;
5904
- const value = args[index + 1];
5905
- if (!value || value.startsWith("--")) return null;
5906
- return value;
5907
- }
5908
-
5909
- function parsePositiveIntegerOption(args, name, fallback) {
5910
- const value = readCliOptionValue(args, name);
5911
- if (value == null) return fallback;
5912
- const parsed = Number.parseInt(value, 10);
5913
- if (!Number.isFinite(parsed) || parsed < 0) {
5914
- throw createCliError(`${name} 값은 0 이상의 정수여야 합니다`, {
5915
- exitCode: EXIT_ARG_ERROR,
5916
- reason: "argError",
5917
- fix: `${name} ${fallback}`,
5918
- });
5919
- }
5920
- return parsed;
5921
- }
5922
-
5923
- function applyAutoDispatchDecision(parsedArgs, decision = null) {
5924
- const resolved = decision || decideDispatchMode(parsedArgs);
5925
- if (resolved.warning) {
5926
- process.stderr.write(`${resolved.warning}\n`);
5927
- }
5928
- if (resolved.escalated) {
5929
- const logLine = `${JSON.stringify({
5930
- at: new Date().toISOString(),
5931
- issue: 281,
5932
- from: "multi",
5933
- to: "swarm",
5934
- tasksCount: Array.isArray(parsedArgs.tasks)
5935
- ? parsedArgs.tasks.length
5936
- : (parsedArgs.tasks ?? null),
5937
- reason: resolved.reason,
5938
- })}\n`;
5939
- try {
5940
- appendFileSync(".omc/state/auto-escalation.log", logLine, "utf8");
5941
- } catch {
5942
- // Fresh checkouts may not have .omc/state yet; dispatch should continue.
5943
- }
5944
- }
5945
- return resolved;
5946
- }
3746
+ return parsed;
3747
+ }
5947
3748
 
5948
3749
  async function main() {
5949
3750
  const cmd = NORMALIZED_ARGS[0] || "help";
@@ -5954,20 +3755,6 @@ async function main() {
5954
3755
  }).catch(() => {});
5955
3756
 
5956
3757
  switch (cmd) {
5957
- case "auto": {
5958
- if (cmdArgs.some(isHelpArg)) {
5959
- printCommandHelp("auto");
5960
- return;
5961
- }
5962
- const parsedArgs = parseRouteArgs(cmdArgs);
5963
- const decision = applyAutoDispatchDecision(parsedArgs);
5964
- if (JSON_OUTPUT) {
5965
- printJson({ args: parsedArgs, dispatch: decision });
5966
- } else {
5967
- process.stdout.write(`${decision.mode}\n`);
5968
- }
5969
- return;
5970
- }
5971
3758
  case "setup":
5972
3759
  if (cmdArgs.some(isHelpArg)) {
5973
3760
  printCommandHelp("setup");
@@ -5976,16 +3763,9 @@ async function main() {
5976
3763
  cmdSetup({
5977
3764
  dryRun: cmdArgs.includes("--dry-run"),
5978
3765
  fromUpdate: cmdArgs.includes("--from-update"),
5979
- enableHubAutostart: cmdArgs.includes("--enable-hub-autostart"),
3766
+ mods: cmdArgs.includes("--mods"),
5980
3767
  });
5981
3768
  return;
5982
- case "stealth-fetch":
5983
- if (cmdArgs.some(isHelpArg)) {
5984
- printCommandHelp("stealth-fetch");
5985
- return;
5986
- }
5987
- await stealthFetchMain([process.argv[0], "stealth-fetch", ...cmdArgs]);
5988
- return;
5989
3769
  case "doctor": {
5990
3770
  if (cmdArgs.some(isHelpArg)) {
5991
3771
  printCommandHelp("doctor");
@@ -6028,57 +3808,13 @@ async function main() {
6028
3808
  }
6029
3809
  return;
6030
3810
  }
6031
- if (cmdArgs.includes("--dynamic-routing")) {
6032
- const { diagnoseDynamicRouting } = await import(
6033
- "../scripts/doctor-dynamic-routing.mjs"
6034
- );
6035
- const report = await diagnoseDynamicRouting();
6036
- if (JSON_OUTPUT) {
6037
- console.log(JSON.stringify(report, null, 2));
6038
- } else {
6039
- const mark = (b) =>
6040
- b ? `${GREEN_BRIGHT}✓${RESET}` : `${RED}✗${RESET}`;
6041
- console.log(
6042
- `\n ${AMBER}${BOLD}⬡ triflux doctor — dynamic routing${RESET}\n`,
6043
- );
6044
- console.log(
6045
- ` ${mark(report.enabled)} enabled: ${report.enabled} (TRIFLUX_DYNAMIC_ROUTING=${report.envFlag ?? "미설정"})`,
6046
- );
6047
- console.log(
6048
- ` ${mark(report.policyLoaded)} policy loaded: ${report.policyLoaded} (scenarios=${report.policyScenarios.length})`,
6049
- );
6050
- console.log(
6051
- ` ${mark(report.snapshotCached)} snapshot cache: ${
6052
- report.snapshotCached
6053
- ? `hit (age=${report.snapshotAgeMs}ms)`
6054
- : "miss"
6055
- }`,
6056
- );
6057
- if (report.previewDecision) {
6058
- const d = report.previewDecision;
6059
- console.log(
6060
- ` ${GREEN_BRIGHT}→${RESET} preview decision: scenario=${d.scenario}, mode=${d.mode}, lane=${d.lane}, shards[0].cli=${d.shards?.[0]?.cli ?? "?"}`,
6061
- );
6062
- }
6063
- if (report.error) {
6064
- console.log(` ${RED}✗${RESET} error: ${report.error}`);
6065
- }
6066
- console.log("");
6067
- }
6068
- return;
6069
- }
6070
3811
  const fix = cmdArgs.includes("--fix");
6071
3812
  const reset = cmdArgs.includes("--reset");
6072
3813
  const purgeLogs = cmdArgs.includes("--purge-logs");
6073
- const cleanupStaleHubs = cmdArgs.includes("--cleanup-stale-hubs");
6074
3814
  const cleanupStaleTmux = cmdArgs.includes("--cleanup-stale-tmux");
6075
3815
  const cleanupApply = cmdArgs.includes("--apply");
6076
3816
  const cleanupDryRun = cmdArgs.includes("--dry-run") || !cleanupApply;
6077
- if (
6078
- (cleanupStaleHubs || cleanupStaleTmux) &&
6079
- cleanupApply &&
6080
- cmdArgs.includes("--dry-run")
6081
- ) {
3817
+ if (cleanupStaleTmux && cleanupApply && cmdArgs.includes("--dry-run")) {
6082
3818
  throw createCliError(
6083
3819
  "cleanup 옵션에서는 --dry-run 과 --apply 중 하나만 지정하세요",
6084
3820
  {
@@ -6099,9 +3835,6 @@ async function main() {
6099
3835
  fix,
6100
3836
  reset,
6101
3837
  purgeLogs,
6102
- cleanupStaleHubs,
6103
- cleanupStaleHubsDryRun: cleanupDryRun,
6104
- cleanupStaleHubsApply: cleanupApply,
6105
3838
  cleanupStaleTmux,
6106
3839
  cleanupStaleTmuxDryRun: cleanupDryRun,
6107
3840
  cleanupStaleTmuxApply: cleanupApply,
@@ -6114,9 +3847,6 @@ async function main() {
6114
3847
  case "mcp":
6115
3848
  cmdMcp(cmdArgs, { json: JSON_OUTPUT });
6116
3849
  return;
6117
- case "schema":
6118
- cmdSchema(cmdArgs);
6119
- return;
6120
3850
  case "update":
6121
3851
  await cmdUpdate(cmdArgs);
6122
3852
  return;
@@ -6128,29 +3858,6 @@ async function main() {
6128
3858
  }
6129
3859
  cmdList({ json: JSON_OUTPUT });
6130
3860
  return;
6131
- case "handoff":
6132
- cmdHandoff(cmdArgs, { json: JSON_OUTPUT });
6133
- return;
6134
- case "hub":
6135
- if (cmdArgs.some(isHelpArg)) {
6136
- printCommandHelp("hub");
6137
- return;
6138
- }
6139
- await cmdHub(cmdArgs, {
6140
- json:
6141
- JSON_OUTPUT && ["status", "ensure"].includes(cmdArgs[0] || "status"),
6142
- });
6143
- return;
6144
- case "monitor": {
6145
- if (cmdArgs.some(isHelpArg)) {
6146
- printCommandHelp("monitor");
6147
- return;
6148
- }
6149
- const { createMonitor } = await import("../tui/monitor.mjs");
6150
- const mon = createMonitor({ targetPane: process.env.TMUX_PANE });
6151
- await mon.start();
6152
- break;
6153
- }
6154
3861
  case "cto": {
6155
3862
  if (cmdArgs.some(isHelpArg)) {
6156
3863
  printCommandHelp("cto");
@@ -6164,16 +3871,16 @@ async function main() {
6164
3871
  return;
6165
3872
  }
6166
3873
  case "multi": {
6167
- const subcommand = cmdArgs[0] || "";
6168
3874
  if (cmdArgs.some(isHelpArg)) {
6169
- printCommandHelp("multi");
3875
+ const { pathToFileURL } = await import("node:url");
3876
+ const { renderTeamHelp } = await import(
3877
+ pathToFileURL(join(PKG_ROOT, "hub", "team", "cli", "help.mjs")).href
3878
+ );
3879
+ renderTeamHelp();
6170
3880
  return;
6171
3881
  }
6172
3882
  if (JSON_OUTPUT) process.env.TFX_OUTPUT_JSON = "1";
6173
3883
  else delete process.env.TFX_OUTPUT_JSON;
6174
- if (subcommand !== "status") {
6175
- await checkHubRunning();
6176
- }
6177
3884
  const { pathToFileURL } = await import("node:url");
6178
3885
  const { cmdTeam } = await import(
6179
3886
  pathToFileURL(join(PKG_ROOT, "hub", "team", "cli", "index.mjs")).href
@@ -6188,190 +3895,6 @@ async function main() {
6188
3895
  }
6189
3896
  return;
6190
3897
  }
6191
- case "codex-team":
6192
- if (cmdArgs.some(isHelpArg)) {
6193
- await cmdCodexTeam(["--help"]);
6194
- return;
6195
- }
6196
- if (JSON_OUTPUT) process.env.TFX_OUTPUT_JSON = "1";
6197
- else delete process.env.TFX_OUTPUT_JSON;
6198
- await checkHubRunning();
6199
- try {
6200
- await cmdCodexTeam(cmdArgs);
6201
- } finally {
6202
- delete process.env.TFX_OUTPUT_JSON;
6203
- }
6204
- return;
6205
- case "notion-read":
6206
- case "nr": {
6207
- if (cmdArgs.some(isHelpArg)) {
6208
- printCommandHelp("notion-read");
6209
- return;
6210
- }
6211
- const scriptPath = join(PKG_ROOT, "scripts", "notion-read.mjs");
6212
- try {
6213
- execFileSync(process.execPath, [scriptPath, ...cmdArgs], {
6214
- stdio: "inherit",
6215
- timeout: 660000,
6216
- windowsHide: true,
6217
- });
6218
- } catch (e) {
6219
- throw createCliError(e.message || "notion-read 실행 실패", {
6220
- exitCode: e.status || EXIT_ERROR,
6221
- reason: "error",
6222
- });
6223
- }
6224
- return;
6225
- }
6226
- case "synapse": {
6227
- const { cmdSynapseStatus } = await import("../hub/team/synapse-cli.mjs");
6228
- const sub = cmdArgs[0] || "status";
6229
- if (isHelpArg(sub)) {
6230
- printCommandHelp("synapse");
6231
- return;
6232
- }
6233
- if (sub !== "status") {
6234
- throw createCliError(`synapse 서브커맨드 미지원: ${sub}`, {
6235
- exitCode: EXIT_ARG_ERROR,
6236
- reason: "argError",
6237
- fix: "tfx synapse status [--json] [--registry <path>]",
6238
- });
6239
- }
6240
- await cmdSynapseStatus(cmdArgs.slice(1), { json: JSON_OUTPUT });
6241
- return;
6242
- }
6243
- case "review": {
6244
- if (cmdArgs.some(isHelpArg)) {
6245
- printCommandHelp("review");
6246
- return;
6247
- }
6248
- const ref =
6249
- cmdArgs[0] && !cmdArgs[0].startsWith("--") ? cmdArgs[0] : "HEAD";
6250
- const baseIdx = cmdArgs.indexOf("--base");
6251
- const base = baseIdx >= 0 ? cmdArgs[baseIdx + 1] : undefined;
6252
- const timeoutIdx = cmdArgs.indexOf("--timeout");
6253
- const timeoutMs =
6254
- timeoutIdx >= 0 ? Number(cmdArgs[timeoutIdx + 1]) * 1000 : 180_000;
6255
- const shardIdx = cmdArgs.indexOf("--shard");
6256
- const shard = shardIdx >= 0 ? cmdArgs[shardIdx + 1] : "off";
6257
-
6258
- if (shard === "per-file") {
6259
- const { runCodexReviewSharded } = await import(
6260
- "../hub/team/codex-review.mjs"
6261
- );
6262
- const result = await runCodexReviewSharded({
6263
- ref,
6264
- base,
6265
- timeoutMs,
6266
- onFileStart: ({ file, index, total }) => {
6267
- if (!JSON_OUTPUT) {
6268
- process.stderr.write(
6269
- `[${index + 1}/${total}] reviewing ${file}\n`,
6270
- );
6271
- }
6272
- },
6273
- });
6274
- if (JSON_OUTPUT) {
6275
- console.log(JSON.stringify(result, null, 2));
6276
- } else {
6277
- console.log(
6278
- `=== per-file review (${result.files.length} files, range=${result.range}) ===`,
6279
- );
6280
- for (const r of result.perFile) {
6281
- console.log("");
6282
- console.log(
6283
- `--- ${r.file} (${r.diffBytes}b, verdict=${r.verdict || "n/a"}${r.skipped ? ", skipped" : ""}) ---`,
6284
- );
6285
- if (r.error) console.log(` error: ${r.error}`);
6286
- if (r.stdout) process.stdout.write(r.stdout);
6287
- }
6288
- console.log("");
6289
- console.log(
6290
- `Aggregate: files=${result.files.length} verdict=${result.verdict}`,
6291
- );
6292
- if (result.error) console.log(`error: ${result.error}`);
6293
- }
6294
- process.exit(result.ok ? 0 : 1);
6295
- }
6296
-
6297
- const { runCodexReview } = await import("../hub/team/codex-review.mjs");
6298
- const result = await runCodexReview({ ref, base, timeoutMs });
6299
- if (JSON_OUTPUT) {
6300
- console.log(JSON.stringify(result, null, 2));
6301
- } else {
6302
- if (result.stdout) process.stdout.write(result.stdout);
6303
- if (result.stderr) process.stderr.write(result.stderr);
6304
- console.log("");
6305
- console.log(
6306
- `range=${result.range || ref} diffBytes=${result.diffBytes} verdict=${result.verdict || "n/a"}`,
6307
- );
6308
- if (result.error) console.log(`error: ${result.error}`);
6309
- }
6310
- process.exit(result.ok ? 0 : 1);
6311
- return;
6312
- }
6313
- case "swarm": {
6314
- const sub = cmdArgs[0] || "";
6315
- if (sub === "help" || sub === "--help" || sub === "-h") {
6316
- const s = CLI_COMMAND_SCHEMAS.swarm;
6317
- console.log(`
6318
- ${AMBER}${BOLD}⬡ tfx swarm${RESET}
6319
-
6320
- ${GRAY}${s.description}${RESET}
6321
-
6322
- ${BOLD}Usage${RESET}
6323
- ${WHITE_BRIGHT}${s.usage}${RESET}
6324
-
6325
- ${BOLD}Subcommands${RESET}
6326
- ${WHITE_BRIGHT}tfx swarm run <prd>${RESET} ${GRAY}${s.subcommands.run}${RESET}
6327
- ${WHITE_BRIGHT}tfx swarm preflight <prd>${RESET} ${GRAY}${s.subcommands.preflight}${RESET}
6328
- ${WHITE_BRIGHT}tfx swarm plan <prd>${RESET} ${GRAY}${s.subcommands.plan}${RESET}
6329
- ${WHITE_BRIGHT}tfx swarm list${RESET} ${GRAY}${s.subcommands.list}${RESET}
6330
- ${WHITE_BRIGHT}tfx swarm status${RESET} ${GRAY}${s.subcommands.status}${RESET}
6331
-
6332
- ${BOLD}Options${RESET}
6333
- ${s.options.map((o) => ` ${DIM}${o.name.padEnd(16)}${RESET} ${GRAY}${o.description}${RESET}`).join("\n")}
6334
- `);
6335
- return;
6336
- }
6337
- await checkHubRunning();
6338
- const { cmdSwarmRun, cmdSwarmPlan, cmdSwarmList, cmdSwarmPreflight } =
6339
- await import("../hub/team/swarm-cli.mjs");
6340
- if (sub === "list" || sub === "status") {
6341
- await cmdSwarmList(cmdArgs.slice(1), { json: JSON_OUTPUT });
6342
- return;
6343
- }
6344
- if (sub === "plan") {
6345
- await cmdSwarmPlan(cmdArgs.slice(1), { json: JSON_OUTPUT });
6346
- return;
6347
- }
6348
- if (sub === "preflight") {
6349
- await cmdSwarmPreflight(cmdArgs.slice(1), { json: JSON_OUTPUT });
6350
- return;
6351
- }
6352
- if (sub === "run") {
6353
- await cmdSwarmRun(cmdArgs.slice(1), { json: JSON_OUTPUT });
6354
- return;
6355
- }
6356
- if (!sub || sub.startsWith("--")) {
6357
- throw createCliError("PRD 경로가 필요합니다", {
6358
- exitCode: EXIT_ARG_ERROR,
6359
- reason: "argError",
6360
- fix: "tfx swarm [run] <prd-path> [--dry-run|--json|--filter <shard>]",
6361
- });
6362
- }
6363
- await cmdSwarmRun(cmdArgs, { json: JSON_OUTPUT });
6364
- return;
6365
- }
6366
- case "why": {
6367
- if (cmdArgs.some(isHelpArg)) {
6368
- printCommandHelp("why");
6369
- return;
6370
- }
6371
- const { cmdSynapseWhy } = await import("../hub/team/synapse-cli.mjs");
6372
- await cmdSynapseWhy(cmdArgs, { json: JSON_OUTPUT });
6373
- return;
6374
- }
6375
3898
  case "version":
6376
3899
  case "--version":
6377
3900
  case "-v":