triflux 7.2.2 → 7.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/hub/intent.mjs +4 -3
- package/hub/pipeline/gates/consensus.mjs +94 -0
- package/hub/pipeline/gates/index.mjs +1 -0
- package/hub/routing/complexity.mjs +166 -0
- package/hub/routing/index.mjs +117 -0
- package/hub/routing/q-learning.mjs +335 -0
- package/hub/team/backend.mjs +88 -0
- package/hub/team/cli/commands/start/index.mjs +103 -102
- package/hub/team/headless.mjs +4 -10
- package/package.json +1 -1
package/hub/intent.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// hub/intent.mjs — Intent Classification Engine
|
|
2
2
|
// 사용자 요청의 "진짜 의도"를 분석 → 카테고리 분류 → 최적 에이전트/모델 자동 선택
|
|
3
3
|
|
|
4
|
-
import { execFileSync
|
|
4
|
+
import { execFileSync } from 'node:child_process';
|
|
5
5
|
import crypto from 'node:crypto';
|
|
6
6
|
|
|
7
7
|
/** 캐시 엔트리: { category, confidence, ts } */
|
|
@@ -39,8 +39,9 @@ function _getCached(hash) {
|
|
|
39
39
|
|
|
40
40
|
function _tryCodexClassify(prompt) {
|
|
41
41
|
try {
|
|
42
|
-
const raw =
|
|
43
|
-
|
|
42
|
+
const raw = execFileSync(
|
|
43
|
+
'codex',
|
|
44
|
+
['exec', `Classify intent: ${prompt}. Reply JSON: {intent, confidence}`],
|
|
44
45
|
{ timeout: 8000, encoding: 'utf8' }
|
|
45
46
|
);
|
|
46
47
|
// JSON 블록 추출 (응답에 다른 텍스트가 섞일 수 있음)
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// hub/pipeline/gates/consensus.mjs — Consensus Quality Gate
|
|
2
|
+
//
|
|
3
|
+
// N개 결과의 합의도를 평가하여 5단계 분기 결정
|
|
4
|
+
// proceed(>=90%) / proceed_warn(>=75%) / retry(<75%+재시도) / escalate(<75%+감독) / abort
|
|
5
|
+
|
|
6
|
+
/** 단계별 합의 임계값 (%) */
|
|
7
|
+
export const STAGE_THRESHOLDS = {
|
|
8
|
+
plan: 50,
|
|
9
|
+
define: 75,
|
|
10
|
+
execute: 75,
|
|
11
|
+
verify: 80,
|
|
12
|
+
security: 100,
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/** 환경변수 기반 기본 임계값 (기본 75) */
|
|
16
|
+
function getDefaultThreshold() {
|
|
17
|
+
const env = typeof process !== 'undefined' && process.env?.TRIFLUX_CONSENSUS_THRESHOLD;
|
|
18
|
+
if (env != null && env !== '') {
|
|
19
|
+
const parsed = Number(env);
|
|
20
|
+
if (!Number.isNaN(parsed) && parsed >= 0 && parsed <= 100) return parsed;
|
|
21
|
+
}
|
|
22
|
+
return 75;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* 성공률 기반 5단계 분기 결정
|
|
27
|
+
* @param {number} successRate - 합의 성공률 (0-100)
|
|
28
|
+
* @param {number} retryCount - 현재 재시도 횟수
|
|
29
|
+
* @param {number} maxRetries - 최대 재시도 횟수
|
|
30
|
+
* @param {string} [mode] - 실행 모드 ('supervised' | 기타)
|
|
31
|
+
* @returns {'proceed' | 'proceed_warn' | 'retry' | 'escalate' | 'abort'}
|
|
32
|
+
*/
|
|
33
|
+
export function evaluateQualityBranch(successRate, retryCount, maxRetries, mode) {
|
|
34
|
+
if (successRate >= 90) return 'proceed';
|
|
35
|
+
if (successRate >= 75) return 'proceed_warn';
|
|
36
|
+
|
|
37
|
+
// <75%: 재시도 가능 여부에 따라 분기
|
|
38
|
+
if (retryCount < maxRetries) return 'retry';
|
|
39
|
+
if (mode === 'supervised') return 'escalate';
|
|
40
|
+
return 'abort';
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* N개 결과의 합의도 평가
|
|
45
|
+
* @param {Array<{ success: boolean }>} results - 평가 대상 결과 배열
|
|
46
|
+
* @param {object} [options]
|
|
47
|
+
* @param {string} [options.stage] - 파이프라인 단계 (STAGE_THRESHOLDS 키)
|
|
48
|
+
* @param {number} [options.threshold] - 합의 임계값 직접 지정 (stage보다 우선)
|
|
49
|
+
* @param {number} [options.retryCount=0] - 현재 재시도 횟수
|
|
50
|
+
* @param {number} [options.maxRetries=2] - 최대 재시도 횟수
|
|
51
|
+
* @param {string} [options.mode] - 실행 모드 ('supervised' 등)
|
|
52
|
+
* @returns {{ successRate: number, threshold: number, decision: string, reasoning: string, results: Array }}
|
|
53
|
+
*/
|
|
54
|
+
export function evaluateConsensus(results, options = {}) {
|
|
55
|
+
if (!Array.isArray(results) || results.length === 0) {
|
|
56
|
+
return {
|
|
57
|
+
successRate: 0,
|
|
58
|
+
threshold: options.threshold ?? getDefaultThreshold(),
|
|
59
|
+
decision: 'abort',
|
|
60
|
+
reasoning: '평가 대상 결과가 없습니다.',
|
|
61
|
+
results: [],
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const retryCount = options.retryCount ?? 0;
|
|
66
|
+
const maxRetries = options.maxRetries ?? 2;
|
|
67
|
+
const mode = options.mode;
|
|
68
|
+
|
|
69
|
+
// 임계값 결정: 직접 지정 > stage별 > 환경변수 > 기본 75
|
|
70
|
+
const threshold = options.threshold
|
|
71
|
+
?? (options.stage && STAGE_THRESHOLDS[options.stage])
|
|
72
|
+
?? getDefaultThreshold();
|
|
73
|
+
|
|
74
|
+
const successCount = results.filter(r => r.success).length;
|
|
75
|
+
const successRate = Math.round((successCount / results.length) * 100);
|
|
76
|
+
|
|
77
|
+
const decision = evaluateQualityBranch(successRate, retryCount, maxRetries, mode);
|
|
78
|
+
|
|
79
|
+
const reasoningMap = {
|
|
80
|
+
proceed: `합의율 ${successRate}% (>= 90%): 전원 합의. 진행.`,
|
|
81
|
+
proceed_warn: `합의율 ${successRate}% (>= 75%): 부분 합의. 경고와 함께 진행.`,
|
|
82
|
+
retry: `합의율 ${successRate}% (< 75%): 재시도 ${retryCount + 1}/${maxRetries} 가능.`,
|
|
83
|
+
escalate: `합의율 ${successRate}% (< 75%): 감독 모드 에스컬레이션.`,
|
|
84
|
+
abort: `합의율 ${successRate}% (< 75%): 합의 실패. 중단.`,
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
successRate,
|
|
89
|
+
threshold,
|
|
90
|
+
decision,
|
|
91
|
+
reasoning: reasoningMap[decision],
|
|
92
|
+
results,
|
|
93
|
+
};
|
|
94
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// hub/routing/complexity.mjs — 작업 복잡도 스코어링
|
|
2
|
+
// 작업 설명 텍스트에서 복잡도를 0-1 범위로 계산한다.
|
|
3
|
+
// 외부 의존성 없음 (순수 텍스트 분석)
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* 복잡도 지표 키워드 사전
|
|
7
|
+
* 카테고리별 키워드와 가중치 (0-1)
|
|
8
|
+
*/
|
|
9
|
+
const COMPLEXITY_INDICATORS = {
|
|
10
|
+
// 높은 복잡도 (0.7-1.0)
|
|
11
|
+
high: {
|
|
12
|
+
keywords: [
|
|
13
|
+
'refactor', 'architecture', 'security', 'migration', 'distributed',
|
|
14
|
+
'concurrent', 'parallel', 'optimization', 'performance', 'scalability',
|
|
15
|
+
'cryptograph', 'encryption', 'authentication', 'authorization',
|
|
16
|
+
'database schema', 'data model', 'state machine', 'event-driven',
|
|
17
|
+
'microservice', 'orchestrat', 'pipeline', 'workflow',
|
|
18
|
+
// 한국어
|
|
19
|
+
'리팩터링', '리팩토링', '아키텍처', '보안', '마이그레이션', '분산',
|
|
20
|
+
'동시성', '병렬', '최적화', '성능', '확장성',
|
|
21
|
+
'암호화', '인증', '인가', '데이터베이스 스키마', '데이터 모델',
|
|
22
|
+
'상태 머신', '이벤트 드리븐', '마이크로서비스', '오케스트레이션',
|
|
23
|
+
],
|
|
24
|
+
weight: 0.85,
|
|
25
|
+
},
|
|
26
|
+
// 중간 복잡도 (0.4-0.7)
|
|
27
|
+
medium: {
|
|
28
|
+
keywords: [
|
|
29
|
+
'implement', 'integrate', 'api', 'endpoint', 'middleware',
|
|
30
|
+
'validation', 'error handling', 'testing', 'debug', 'fix bug',
|
|
31
|
+
'configuration', 'deploy', 'ci/cd', 'docker', 'container',
|
|
32
|
+
'cache', 'queue', 'webhook', 'notification', 'logging',
|
|
33
|
+
// 한국어
|
|
34
|
+
'구현', '통합', '엔드포인트', '미들웨어', '유효성 검사',
|
|
35
|
+
'에러 처리', '오류 처리', '테스트', '디버깅', '버그 수정',
|
|
36
|
+
'설정', '배포', '컨테이너', '캐시', '알림', '로깅',
|
|
37
|
+
],
|
|
38
|
+
weight: 0.55,
|
|
39
|
+
},
|
|
40
|
+
// 낮은 복잡도 (0.1-0.4)
|
|
41
|
+
low: {
|
|
42
|
+
keywords: [
|
|
43
|
+
'readme', 'comment', 'typo', 'rename', 'format', 'lint',
|
|
44
|
+
'update version', 'bump', 'add dependency', 'install',
|
|
45
|
+
'simple', 'trivial', 'minor', 'small change', 'one-liner',
|
|
46
|
+
// 한국어
|
|
47
|
+
'문서화', '주석', '오타', '이름 변경', '포맷', '버전 업데이트',
|
|
48
|
+
'의존성 추가', '설치', '간단', '사소한', '소규모', '한 줄',
|
|
49
|
+
],
|
|
50
|
+
weight: 0.2,
|
|
51
|
+
},
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* 어휘 복잡도 계산 (20%)
|
|
56
|
+
* 고유 단어 비율 + 평균 단어 길이 기반
|
|
57
|
+
* @param {string[]} words
|
|
58
|
+
* @returns {number} 0-1
|
|
59
|
+
*/
|
|
60
|
+
function lexicalComplexity(words) {
|
|
61
|
+
if (words.length === 0) return 0;
|
|
62
|
+
const unique = new Set(words);
|
|
63
|
+
const typeTokenRatio = unique.size / words.length;
|
|
64
|
+
const avgWordLen = words.reduce((sum, w) => sum + w.length, 0) / words.length;
|
|
65
|
+
// 긴 단어(기술 용어)가 많을수록 복잡
|
|
66
|
+
const lenScore = Math.min(avgWordLen / 10, 1);
|
|
67
|
+
return typeTokenRatio * 0.5 + lenScore * 0.5;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* 시맨틱 깊이 계산 (35%)
|
|
72
|
+
* 키워드 사전 매칭 기반
|
|
73
|
+
* @param {string} text
|
|
74
|
+
* @returns {number} 0-1
|
|
75
|
+
*/
|
|
76
|
+
function semanticDepth(text) {
|
|
77
|
+
const lower = text.toLowerCase();
|
|
78
|
+
let maxWeight = 0;
|
|
79
|
+
let matchCount = 0;
|
|
80
|
+
|
|
81
|
+
for (const [, category] of Object.entries(COMPLEXITY_INDICATORS)) {
|
|
82
|
+
for (const kw of category.keywords) {
|
|
83
|
+
if (lower.includes(kw)) {
|
|
84
|
+
matchCount++;
|
|
85
|
+
if (category.weight > maxWeight) maxWeight = category.weight;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
// 매칭 키워드 수와 최고 가중치 조합
|
|
90
|
+
const countScore = Math.min(matchCount / 5, 1);
|
|
91
|
+
return maxWeight * 0.6 + countScore * 0.4;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* 작업 범위 계산 (25%)
|
|
96
|
+
* 문장 수, 줄 수, 파일/경로 참조 기반
|
|
97
|
+
* @param {string} text
|
|
98
|
+
* @returns {number} 0-1
|
|
99
|
+
*/
|
|
100
|
+
function taskScope(text) {
|
|
101
|
+
const lines = text.split('\n').filter((l) => l.trim().length > 0);
|
|
102
|
+
const sentences = text.split(/[.!?]+/).filter((s) => s.trim().length > 0);
|
|
103
|
+
const fileRefs = (text.match(/[\w\-/]+\.\w{1,5}/g) || []).length;
|
|
104
|
+
|
|
105
|
+
const lineScore = Math.min(lines.length / 20, 1);
|
|
106
|
+
const sentenceScore = Math.min(sentences.length / 10, 1);
|
|
107
|
+
const fileScore = Math.min(fileRefs / 5, 1);
|
|
108
|
+
|
|
109
|
+
return lineScore * 0.3 + sentenceScore * 0.3 + fileScore * 0.4;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* 불확실성 계산 (20%)
|
|
114
|
+
* 모호한 표현, 질문, 조건문 기반
|
|
115
|
+
* @param {string} text
|
|
116
|
+
* @returns {number} 0-1
|
|
117
|
+
*/
|
|
118
|
+
function uncertainty(text) {
|
|
119
|
+
const lower = text.toLowerCase();
|
|
120
|
+
const uncertainWords = [
|
|
121
|
+
'maybe', 'perhaps', 'might', 'could', 'possibly', 'unclear',
|
|
122
|
+
'not sure', 'investigate', 'explore', 'research', 'try',
|
|
123
|
+
'consider', 'evaluate', 'assess', 'determine', 'figure out',
|
|
124
|
+
];
|
|
125
|
+
let count = 0;
|
|
126
|
+
for (const w of uncertainWords) {
|
|
127
|
+
if (lower.includes(w)) count++;
|
|
128
|
+
}
|
|
129
|
+
const questions = (text.match(/\?/g) || []).length;
|
|
130
|
+
const wordScore = Math.min(count / 4, 1);
|
|
131
|
+
const questionScore = Math.min(questions / 3, 1);
|
|
132
|
+
return wordScore * 0.6 + questionScore * 0.4;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* 작업 복잡도 스코어링
|
|
137
|
+
* @param {string} taskDescription — 작업 설명 텍스트
|
|
138
|
+
* @returns {{ score: number, breakdown: { lexical: number, semantic: number, scope: number, uncertainty: number } }}
|
|
139
|
+
*/
|
|
140
|
+
export function scoreComplexity(taskDescription) {
|
|
141
|
+
if (!taskDescription || typeof taskDescription !== 'string') {
|
|
142
|
+
return { score: 0, breakdown: { lexical: 0, semantic: 0, scope: 0, uncertainty: 0 } };
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const words = taskDescription.toLowerCase().split(/\s+/).filter((w) => w.length > 0);
|
|
146
|
+
const lexical = lexicalComplexity(words);
|
|
147
|
+
const semantic = semanticDepth(taskDescription);
|
|
148
|
+
const scope = taskScope(taskDescription);
|
|
149
|
+
const uncertain = uncertainty(taskDescription);
|
|
150
|
+
|
|
151
|
+
// 가중 합산: 어휘(20%) + 시맨틱(35%) + 범위(25%) + 불확실성(20%)
|
|
152
|
+
const score = Math.min(
|
|
153
|
+
lexical * 0.20 + semantic * 0.35 + scope * 0.25 + uncertain * 0.20,
|
|
154
|
+
1,
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
return {
|
|
158
|
+
score: Math.round(score * 1000) / 1000,
|
|
159
|
+
breakdown: {
|
|
160
|
+
lexical: Math.round(lexical * 1000) / 1000,
|
|
161
|
+
semantic: Math.round(semantic * 1000) / 1000,
|
|
162
|
+
scope: Math.round(scope * 1000) / 1000,
|
|
163
|
+
uncertainty: Math.round(uncertain * 1000) / 1000,
|
|
164
|
+
},
|
|
165
|
+
};
|
|
166
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// hub/routing/index.mjs — 통합 라우팅 진입점
|
|
2
|
+
// Q-Learning 동적 라우팅 + agent-map.json 정적 폴백
|
|
3
|
+
// 환경변수 TRIFLUX_DYNAMIC_ROUTING=true 로 옵트인 (기본 false)
|
|
4
|
+
|
|
5
|
+
import { createRequire } from 'node:module';
|
|
6
|
+
import { QLearningRouter } from './q-learning.mjs';
|
|
7
|
+
import { scoreComplexity } from './complexity.mjs';
|
|
8
|
+
|
|
9
|
+
const _require = createRequire(import.meta.url);
|
|
10
|
+
|
|
11
|
+
/** agent-map.json 정적 매핑 */
|
|
12
|
+
let AGENT_MAP;
|
|
13
|
+
try {
|
|
14
|
+
AGENT_MAP = _require('../team/agent-map.json');
|
|
15
|
+
} catch {
|
|
16
|
+
AGENT_MAP = {};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** 싱글턴 라우터 인스턴스 (lazy init) */
|
|
20
|
+
let _router = null;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* 라우터 인스턴스 획득 (lazy singleton)
|
|
24
|
+
* @returns {QLearningRouter}
|
|
25
|
+
*/
|
|
26
|
+
function getRouter() {
|
|
27
|
+
if (!_router) {
|
|
28
|
+
_router = new QLearningRouter();
|
|
29
|
+
_router.load();
|
|
30
|
+
}
|
|
31
|
+
return _router;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* 동적 라우팅 활성화 여부
|
|
36
|
+
* @returns {boolean}
|
|
37
|
+
*/
|
|
38
|
+
function isDynamicRoutingEnabled() {
|
|
39
|
+
const env = process.env.TRIFLUX_DYNAMIC_ROUTING;
|
|
40
|
+
return env === 'true' || env === '1';
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* 통합 라우팅 결정
|
|
45
|
+
* 우선순위: Q-Learning 예측 (신뢰도 >= 0.6) -> agent-map.json 기본값
|
|
46
|
+
*
|
|
47
|
+
* @param {string} agentType — 에이전트 역할명 ("executor", "designer" 등)
|
|
48
|
+
* @param {string} [taskDescription=''] — 작업 설명 (동적 라우팅용)
|
|
49
|
+
* @returns {{ cliType: string, source: 'dynamic' | 'static', confidence: number, complexity: number }}
|
|
50
|
+
*/
|
|
51
|
+
export function resolveRoute(agentType, taskDescription = '') {
|
|
52
|
+
// 정적 기본값
|
|
53
|
+
const staticCli = AGENT_MAP[agentType] || agentType;
|
|
54
|
+
const { score: complexity } = scoreComplexity(taskDescription);
|
|
55
|
+
|
|
56
|
+
// 동적 라우팅 비활성화 시 정적 매핑 반환
|
|
57
|
+
if (!isDynamicRoutingEnabled()) {
|
|
58
|
+
return { cliType: staticCli, source: 'static', confidence: 1, complexity };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
// 작업 설명 없으면 정적 폴백
|
|
62
|
+
if (!taskDescription || taskDescription.trim().length === 0) {
|
|
63
|
+
return { cliType: staticCli, source: 'static', confidence: 1, complexity };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const router = getRouter();
|
|
67
|
+
const prediction = router.predict(taskDescription);
|
|
68
|
+
|
|
69
|
+
// 신뢰도 기준 미달 시 정적 폴백
|
|
70
|
+
if (prediction.confidence < 0.6) {
|
|
71
|
+
return { cliType: staticCli, source: 'static', confidence: prediction.confidence, complexity };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
cliType: prediction.action,
|
|
76
|
+
source: 'dynamic',
|
|
77
|
+
confidence: prediction.confidence,
|
|
78
|
+
complexity,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* 라우팅 피드백 업데이트
|
|
84
|
+
* @param {string} taskDescription — 작업 설명
|
|
85
|
+
* @param {string} action — 수행한 CLI 타입
|
|
86
|
+
* @param {number} reward — 보상 (-1 ~ 1)
|
|
87
|
+
* @param {boolean} [persist=true] — 영속화 여부
|
|
88
|
+
*/
|
|
89
|
+
export function updateRoute(taskDescription, action, reward, persist = true) {
|
|
90
|
+
if (!isDynamicRoutingEnabled()) return;
|
|
91
|
+
|
|
92
|
+
const router = getRouter();
|
|
93
|
+
router.update(taskDescription, action, reward);
|
|
94
|
+
if (persist) router.save();
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* 라우터 상태 조회 (진단용)
|
|
99
|
+
* @returns {{ enabled: boolean, epsilon: number, totalUpdates: number, stateCount: number }}
|
|
100
|
+
*/
|
|
101
|
+
export function routerStatus() {
|
|
102
|
+
const enabled = isDynamicRoutingEnabled();
|
|
103
|
+
if (!enabled) {
|
|
104
|
+
return { enabled, epsilon: 0, totalUpdates: 0, stateCount: 0 };
|
|
105
|
+
}
|
|
106
|
+
const router = getRouter();
|
|
107
|
+
return {
|
|
108
|
+
enabled,
|
|
109
|
+
epsilon: router.epsilon,
|
|
110
|
+
totalUpdates: router.totalUpdates,
|
|
111
|
+
stateCount: router.stateCount,
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// re-export for convenience
|
|
116
|
+
export { scoreComplexity } from './complexity.mjs';
|
|
117
|
+
export { QLearningRouter, ACTIONS } from './q-learning.mjs';
|
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
// hub/routing/q-learning.mjs — 테이블 기반 Q-Learning 동적 라우팅
|
|
2
|
+
// agent-map.json 폴백을 유지하면서, 작업 결과 피드백으로 가중치를 학습한다.
|
|
3
|
+
// 외부 의존성 없음 (fs, path, crypto만 사용)
|
|
4
|
+
|
|
5
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import { createHash } from 'node:crypto';
|
|
8
|
+
import { createRequire } from 'node:module';
|
|
9
|
+
import { homedir } from 'node:os';
|
|
10
|
+
import { scoreComplexity } from './complexity.mjs';
|
|
11
|
+
|
|
12
|
+
const _require = createRequire(import.meta.url);
|
|
13
|
+
|
|
14
|
+
/** agent-map.json 정적 매핑 (폴백용) */
|
|
15
|
+
let AGENT_MAP;
|
|
16
|
+
try {
|
|
17
|
+
AGENT_MAP = _require('../team/agent-map.json');
|
|
18
|
+
} catch {
|
|
19
|
+
AGENT_MAP = {};
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** 사용 가능한 CLI 액션 */
|
|
23
|
+
const ACTIONS = ['codex', 'gemini', 'claude', 'haiku', 'sonnet'];
|
|
24
|
+
|
|
25
|
+
/** 특성 벡터 키워드 (48차원, 에이전트 타입 기반) */
|
|
26
|
+
const FEATURE_KEYWORDS = [
|
|
27
|
+
// 실행/구현 (codex 친화)
|
|
28
|
+
'implement', 'execute', 'build', 'fix', 'debug', 'code', 'refactor', 'test',
|
|
29
|
+
// 분석/설계 (claude/sonnet 친화)
|
|
30
|
+
'analyze', 'architect', 'plan', 'review', 'security', 'optimize', 'research', 'evaluate',
|
|
31
|
+
// 디자인/문서 (gemini 친화)
|
|
32
|
+
'design', 'ui', 'ux', 'frontend', 'visual', 'document', 'write', 'explain',
|
|
33
|
+
// 간단/빠른 (haiku 친화)
|
|
34
|
+
'simple', 'quick', 'trivial', 'rename', 'format', 'lint', 'typo', 'minor',
|
|
35
|
+
// 한국어 — 실행/구현 (codex 친화)
|
|
36
|
+
'구현', '빌드', '수정', '디버깅', '리팩터링', '테스트',
|
|
37
|
+
// 한국어 — 분석/설계 (claude/sonnet 친화)
|
|
38
|
+
'분석', '아키텍처', '설계', '검토', '보안', '최적화',
|
|
39
|
+
// 한국어 — 디자인/문서 (gemini 친화)
|
|
40
|
+
'디자인', '문서화',
|
|
41
|
+
// 한국어 — 간단/빠른 (haiku 친화)
|
|
42
|
+
'간단', '사소한',
|
|
43
|
+
];
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* 텍스트에서 48차원 특성 벡터 추출
|
|
47
|
+
* 단어 경계를 기준으로 매칭하여 부분 문자열 오탐을 방지한다.
|
|
48
|
+
* @param {string} text
|
|
49
|
+
* @returns {number[]} 48-dim binary feature vector
|
|
50
|
+
*/
|
|
51
|
+
function extractFeatures(text) {
|
|
52
|
+
const lower = text.toLowerCase();
|
|
53
|
+
return FEATURE_KEYWORDS.map((kw) => {
|
|
54
|
+
// 영문 단일 단어: 단어 경계(\b) 적용
|
|
55
|
+
// 한국어 또는 다중 단어 구문: 공백/문장 경계 기반 포함 여부 확인
|
|
56
|
+
if (/^[a-z]+$/.test(kw)) {
|
|
57
|
+
return new RegExp(`\\b${kw}\\b`).test(lower) ? 1 : 0;
|
|
58
|
+
}
|
|
59
|
+
return lower.includes(kw) ? 1 : 0;
|
|
60
|
+
});
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* 특성 벡터를 상태 키로 변환 (해시 기반)
|
|
65
|
+
* @param {number[]} features
|
|
66
|
+
* @returns {string}
|
|
67
|
+
*/
|
|
68
|
+
function stateKey(features) {
|
|
69
|
+
const hash = createHash('sha256').update(features.join(',')).digest('hex');
|
|
70
|
+
return hash.slice(0, 16);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* LRU 캐시 (예측 결과 캐싱)
|
|
75
|
+
*/
|
|
76
|
+
class LRUCache {
|
|
77
|
+
/** @param {number} maxSize @param {number} ttlMs */
|
|
78
|
+
constructor(maxSize = 256, ttlMs = 5 * 60 * 1000) {
|
|
79
|
+
this._maxSize = maxSize;
|
|
80
|
+
this._ttlMs = ttlMs;
|
|
81
|
+
/** @type {Map<string, { value: *, ts: number }>} */
|
|
82
|
+
this._cache = new Map();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
get(key) {
|
|
86
|
+
const entry = this._cache.get(key);
|
|
87
|
+
if (!entry) return undefined;
|
|
88
|
+
if (Date.now() - entry.ts > this._ttlMs) {
|
|
89
|
+
this._cache.delete(key);
|
|
90
|
+
return undefined;
|
|
91
|
+
}
|
|
92
|
+
// LRU: 다시 삽입하여 순서 갱신
|
|
93
|
+
this._cache.delete(key);
|
|
94
|
+
this._cache.set(key, entry);
|
|
95
|
+
return entry.value;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
set(key, value) {
|
|
99
|
+
if (this._cache.has(key)) this._cache.delete(key);
|
|
100
|
+
this._cache.set(key, { value, ts: Date.now() });
|
|
101
|
+
// 초과 시 가장 오래된 항목 제거
|
|
102
|
+
if (this._cache.size > this._maxSize) {
|
|
103
|
+
const oldest = this._cache.keys().next().value;
|
|
104
|
+
this._cache.delete(oldest);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
clear() {
|
|
109
|
+
this._cache.clear();
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Q-Learning 라우터
|
|
115
|
+
* 테이블 기반 Q-Learning으로 작업→CLI 매핑을 학습한다.
|
|
116
|
+
*/
|
|
117
|
+
export class QLearningRouter {
|
|
118
|
+
/**
|
|
119
|
+
* @param {object} [opts]
|
|
120
|
+
* @param {number} [opts.learningRate=0.1] — 학습률 (alpha)
|
|
121
|
+
* @param {number} [opts.discountFactor=0.9] — 할인율 (gamma)
|
|
122
|
+
* @param {number} [opts.epsilon=0.3] — 탐색 확률 (epsilon-greedy)
|
|
123
|
+
* @param {number} [opts.epsilonDecay=0.995] — 엡실론 감쇠율
|
|
124
|
+
* @param {number} [opts.epsilonMin=0.05] — 최소 엡실론
|
|
125
|
+
* @param {number} [opts.minConfidence=0.6] — 최소 신뢰도 (이하면 폴백)
|
|
126
|
+
* @param {string} [opts.modelPath] — Q-table 영속화 경로
|
|
127
|
+
*/
|
|
128
|
+
constructor(opts = {}) {
|
|
129
|
+
this._lr = opts.learningRate ?? 0.1;
|
|
130
|
+
this._gamma = opts.discountFactor ?? 0.9;
|
|
131
|
+
this._epsilon = opts.epsilon ?? 0.3;
|
|
132
|
+
this._epsilonDecay = opts.epsilonDecay ?? 0.995;
|
|
133
|
+
this._epsilonMin = opts.epsilonMin ?? 0.05;
|
|
134
|
+
this._minConfidence = opts.minConfidence ?? 0.6;
|
|
135
|
+
this._modelPath = opts.modelPath ?? join(homedir(), '.omc', 'routing-model.json');
|
|
136
|
+
|
|
137
|
+
/** @type {Map<string, Map<string, number>>} state -> (action -> Q-value) */
|
|
138
|
+
this._qTable = new Map();
|
|
139
|
+
|
|
140
|
+
/** @type {Map<string, number>} state -> visit count */
|
|
141
|
+
this._visitCounts = new Map();
|
|
142
|
+
|
|
143
|
+
/** 총 업데이트 횟수 */
|
|
144
|
+
this._totalUpdates = 0;
|
|
145
|
+
|
|
146
|
+
/** 예측 캐시 */
|
|
147
|
+
this._cache = new LRUCache(256, 5 * 60 * 1000);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* 상태에 대한 Q-values 조회 (없으면 초기화)
|
|
152
|
+
* @param {string} state
|
|
153
|
+
* @returns {Map<string, number>}
|
|
154
|
+
*/
|
|
155
|
+
_getQValues(state) {
|
|
156
|
+
if (!this._qTable.has(state)) {
|
|
157
|
+
const qValues = new Map();
|
|
158
|
+
for (const action of ACTIONS) {
|
|
159
|
+
qValues.set(action, 0);
|
|
160
|
+
}
|
|
161
|
+
this._qTable.set(state, qValues);
|
|
162
|
+
}
|
|
163
|
+
return this._qTable.get(state);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* 작업 설명으로부터 최적 CLI 타입 예측
|
|
168
|
+
* @param {string} taskDescription
|
|
169
|
+
* @returns {{ action: string, confidence: number, exploration: boolean, complexity: number }}
|
|
170
|
+
*/
|
|
171
|
+
predict(taskDescription) {
|
|
172
|
+
const features = extractFeatures(taskDescription);
|
|
173
|
+
const state = stateKey(features);
|
|
174
|
+
|
|
175
|
+
// 캐시 확인
|
|
176
|
+
const cached = this._cache.get(state);
|
|
177
|
+
if (cached) return cached;
|
|
178
|
+
|
|
179
|
+
const qValues = this._getQValues(state);
|
|
180
|
+
const visits = this._visitCounts.get(state) || 0;
|
|
181
|
+
|
|
182
|
+
// 엡실론-그리디: 탐색 vs 활용
|
|
183
|
+
const isExploration = Math.random() < this._epsilon;
|
|
184
|
+
|
|
185
|
+
let action;
|
|
186
|
+
if (isExploration) {
|
|
187
|
+
// 무작위 탐색
|
|
188
|
+
action = ACTIONS[Math.floor(Math.random() * ACTIONS.length)];
|
|
189
|
+
} else {
|
|
190
|
+
// 최적 액션 선택 (최대 Q-value)
|
|
191
|
+
let maxQ = -Infinity;
|
|
192
|
+
action = ACTIONS[0];
|
|
193
|
+
for (const [a, q] of qValues) {
|
|
194
|
+
if (q > maxQ) {
|
|
195
|
+
maxQ = q;
|
|
196
|
+
action = a;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// 신뢰도 계산: 방문 횟수 기반 (최소 10회 이상이면 안정)
|
|
202
|
+
const confidence = visits >= 10
|
|
203
|
+
? Math.min(visits / 50, 1)
|
|
204
|
+
: visits / 10;
|
|
205
|
+
|
|
206
|
+
const { score: complexity } = scoreComplexity(taskDescription);
|
|
207
|
+
|
|
208
|
+
const result = { action, confidence, exploration: isExploration, complexity };
|
|
209
|
+
// 탐색(랜덤) 결과는 캐싱하지 않음 — 매번 새로운 랜덤 액션 생성
|
|
210
|
+
if (!isExploration) this._cache.set(state, result);
|
|
211
|
+
return result;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Q-table 업데이트 (보상 피드백)
|
|
216
|
+
* @param {string} taskDescription — 작업 설명
|
|
217
|
+
* @param {string} action — 수행한 액션 (CLI 타입)
|
|
218
|
+
* @param {number} reward — 보상 (-1 ~ 1)
|
|
219
|
+
*/
|
|
220
|
+
update(taskDescription, action, reward) {
|
|
221
|
+
if (!ACTIONS.includes(action)) return;
|
|
222
|
+
|
|
223
|
+
const features = extractFeatures(taskDescription);
|
|
224
|
+
const state = stateKey(features);
|
|
225
|
+
const qValues = this._getQValues(state);
|
|
226
|
+
const oldQ = qValues.get(action) || 0;
|
|
227
|
+
|
|
228
|
+
// 최대 미래 Q-value (단일 상태이므로 현재 상태의 max)
|
|
229
|
+
let maxFutureQ = -Infinity;
|
|
230
|
+
for (const [, q] of qValues) {
|
|
231
|
+
if (q > maxFutureQ) maxFutureQ = q;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Q-Learning 업데이트: Q(s,a) = Q(s,a) + lr * (reward + gamma * max_Q(s') - Q(s,a))
|
|
235
|
+
const newQ = oldQ + this._lr * (reward + this._gamma * maxFutureQ - oldQ);
|
|
236
|
+
qValues.set(action, newQ);
|
|
237
|
+
|
|
238
|
+
// 방문 횟수 증가
|
|
239
|
+
this._visitCounts.set(state, (this._visitCounts.get(state) || 0) + 1);
|
|
240
|
+
this._totalUpdates++;
|
|
241
|
+
|
|
242
|
+
// 엡실론 감쇠
|
|
243
|
+
this._epsilon = Math.max(this._epsilonMin, this._epsilon * this._epsilonDecay);
|
|
244
|
+
|
|
245
|
+
// 캐시 무효화 (해당 상태)
|
|
246
|
+
this._cache.clear();
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Q-table을 JSON 파일로 영속화
|
|
251
|
+
*/
|
|
252
|
+
save() {
|
|
253
|
+
const dir = join(this._modelPath, '..');
|
|
254
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
255
|
+
|
|
256
|
+
const data = {
|
|
257
|
+
version: 1,
|
|
258
|
+
epsilon: this._epsilon,
|
|
259
|
+
totalUpdates: this._totalUpdates,
|
|
260
|
+
qTable: {},
|
|
261
|
+
visitCounts: {},
|
|
262
|
+
};
|
|
263
|
+
|
|
264
|
+
for (const [state, qValues] of this._qTable) {
|
|
265
|
+
data.qTable[state] = Object.fromEntries(qValues);
|
|
266
|
+
}
|
|
267
|
+
for (const [state, count] of this._visitCounts) {
|
|
268
|
+
data.visitCounts[state] = count;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
writeFileSync(this._modelPath, JSON.stringify(data, null, 2), 'utf8');
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/**
|
|
275
|
+
* 영속화된 Q-table 로드
|
|
276
|
+
* @returns {boolean} 로드 성공 여부
|
|
277
|
+
*/
|
|
278
|
+
load() {
|
|
279
|
+
if (!existsSync(this._modelPath)) return false;
|
|
280
|
+
|
|
281
|
+
try {
|
|
282
|
+
const raw = readFileSync(this._modelPath, 'utf8');
|
|
283
|
+
const data = JSON.parse(raw);
|
|
284
|
+
if (data.version !== 1) return false;
|
|
285
|
+
|
|
286
|
+
this._epsilon = data.epsilon ?? this._epsilon;
|
|
287
|
+
this._totalUpdates = data.totalUpdates ?? 0;
|
|
288
|
+
this._qTable = new Map();
|
|
289
|
+
this._visitCounts = new Map();
|
|
290
|
+
|
|
291
|
+
for (const [state, qObj] of Object.entries(data.qTable || {})) {
|
|
292
|
+
const qValues = new Map();
|
|
293
|
+
for (const [action, q] of Object.entries(qObj)) {
|
|
294
|
+
if (ACTIONS.includes(action)) qValues.set(action, q);
|
|
295
|
+
}
|
|
296
|
+
this._qTable.set(state, qValues);
|
|
297
|
+
}
|
|
298
|
+
for (const [state, count] of Object.entries(data.visitCounts || {})) {
|
|
299
|
+
this._visitCounts.set(state, count);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
this._cache.clear();
|
|
303
|
+
return true;
|
|
304
|
+
} catch {
|
|
305
|
+
return false;
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* agent-map.json 폴백 조회
|
|
311
|
+
* @param {string} agentType — 에이전트 역할명
|
|
312
|
+
* @returns {string} CLI 타입
|
|
313
|
+
*/
|
|
314
|
+
static fallback(agentType) {
|
|
315
|
+
return AGENT_MAP[agentType] || agentType;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** 현재 엡실론 값 */
|
|
319
|
+
get epsilon() {
|
|
320
|
+
return this._epsilon;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/** 총 업데이트 횟수 */
|
|
324
|
+
get totalUpdates() {
|
|
325
|
+
return this._totalUpdates;
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** Q-table 상태 수 */
|
|
329
|
+
get stateCount() {
|
|
330
|
+
return this._qTable.size;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// 모듈 레벨 export
|
|
335
|
+
export { ACTIONS, FEATURE_KEYWORDS, extractFeatures, stateKey, LRUCache };
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
// hub/team/backend.mjs — CLI 백엔드 추상화 레이어
|
|
2
|
+
// 각 CLI(codex/gemini/claude)의 명령 빌드 로직을 클래스로 캡슐화한다.
|
|
3
|
+
// v7.2.2
|
|
4
|
+
import { createRequire } from "node:module";
|
|
5
|
+
|
|
6
|
+
const _require = createRequire(import.meta.url);
|
|
7
|
+
|
|
8
|
+
// ── 백엔드 클래스 ──────────────────────────────────────────────────────────
|
|
9
|
+
|
|
10
|
+
export class CodexBackend {
|
|
11
|
+
name() { return "codex"; }
|
|
12
|
+
command() { return "codex"; }
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @param {string} prompt — 프롬프트 (프롬프트 파일 경로가 아닌 PowerShell 표현식)
|
|
16
|
+
* @param {string} resultFile — 결과 저장 경로
|
|
17
|
+
* @param {object} [opts]
|
|
18
|
+
* @returns {string} PowerShell 명령 (cls 제외)
|
|
19
|
+
*/
|
|
20
|
+
buildArgs(prompt, resultFile, opts = {}) {
|
|
21
|
+
return `codex exec ${prompt} -o '${resultFile}' --color never`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
env() { return {}; }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export class GeminiBackend {
|
|
28
|
+
name() { return "gemini"; }
|
|
29
|
+
command() { return "gemini"; }
|
|
30
|
+
|
|
31
|
+
buildArgs(prompt, resultFile, opts = {}) {
|
|
32
|
+
return `gemini -p ${prompt} -o text > '${resultFile}' 2>'${resultFile}.err'`;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
env() { return {}; }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export class ClaudeBackend {
|
|
39
|
+
name() { return "claude"; }
|
|
40
|
+
command() { return "claude"; }
|
|
41
|
+
|
|
42
|
+
buildArgs(prompt, resultFile, opts = {}) {
|
|
43
|
+
return `claude -p ${prompt} --output-format text > '${resultFile}' 2>&1`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
env() { return {}; }
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ── 레지스트리 ─────────────────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
/** @type {Map<string, CodexBackend|GeminiBackend|ClaudeBackend>} */
|
|
52
|
+
const backends = new Map([
|
|
53
|
+
["codex", new CodexBackend()],
|
|
54
|
+
["gemini", new GeminiBackend()],
|
|
55
|
+
["claude", new ClaudeBackend()],
|
|
56
|
+
]);
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* 백엔드 이름으로 조회한다.
|
|
60
|
+
* @param {string} name — "codex" | "gemini" | "claude"
|
|
61
|
+
* @returns {CodexBackend|GeminiBackend|ClaudeBackend}
|
|
62
|
+
* @throws {Error} 등록되지 않은 이름
|
|
63
|
+
*/
|
|
64
|
+
export function getBackend(name) {
|
|
65
|
+
const b = backends.get(name);
|
|
66
|
+
if (!b) throw new Error(`지원하지 않는 CLI: ${name}`);
|
|
67
|
+
return b;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* 에이전트명 또는 CLI명을 Backend로 해석한다.
|
|
72
|
+
* agent-map.json을 통해 에이전트명 → CLI명으로 변환 후 레지스트리에서 조회한다.
|
|
73
|
+
* @param {string} agentOrCli — "executor", "codex", "designer" 등
|
|
74
|
+
* @returns {CodexBackend|GeminiBackend|ClaudeBackend}
|
|
75
|
+
*/
|
|
76
|
+
export function getBackendForAgent(agentOrCli) {
|
|
77
|
+
const agentMap = _require("./agent-map.json");
|
|
78
|
+
const cliName = agentMap[agentOrCli] || agentOrCli;
|
|
79
|
+
return getBackend(cliName);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* 등록된 모든 백엔드를 반환한다.
|
|
84
|
+
* @returns {Array<CodexBackend|GeminiBackend|ClaudeBackend>}
|
|
85
|
+
*/
|
|
86
|
+
export function listBackends() {
|
|
87
|
+
return Array.from(backends.values());
|
|
88
|
+
}
|
|
@@ -1,102 +1,103 @@
|
|
|
1
|
-
import { decomposeTask } from "../../../orchestrator.mjs";
|
|
2
|
-
import { hasWindowsTerminal, hasWindowsTerminalSession } from "../../../session.mjs";
|
|
3
|
-
import { AMBER, BOLD, DIM, GREEN, RED, RESET, WHITE } from "../../../shared.mjs";
|
|
4
|
-
import { getDefaultHubUrl, getHubInfo, startHubDaemon } from "../../services/hub-client.mjs";
|
|
5
|
-
import { ensureTmuxOrExit } from "../../services/runtime-mode.mjs";
|
|
6
|
-
import { saveTeamState } from "../../services/state-store.mjs";
|
|
7
|
-
import { fail, ok, warn } from "../../render.mjs";
|
|
8
|
-
import { parseTeamArgs } from "./parse-args.mjs";
|
|
9
|
-
import { startInProcessTeam } from "./start-in-process.mjs";
|
|
10
|
-
import { startMuxTeam } from "./start-mux.mjs";
|
|
11
|
-
import { startHeadlessTeam } from "./start-headless.mjs";
|
|
12
|
-
import { startWtTeam } from "./start-wt.mjs";
|
|
13
|
-
|
|
14
|
-
function printStartUsage() {
|
|
15
|
-
console.log(`\n ${AMBER}${BOLD}⬡ tfx multi${RESET}\n`);
|
|
16
|
-
console.log(` 사용법: ${WHITE}tfx multi "작업 설명"${RESET}`);
|
|
17
|
-
console.log(` ${WHITE}tfx multi --agents codex,gemini --lead claude "작업"${RESET}`);
|
|
18
|
-
console.log(` ${WHITE}tfx multi --teammate-mode headless "작업"${RESET} ${DIM}(psmux 헤드리스, 기본)${RESET}`);
|
|
19
|
-
console.log(` ${WHITE}tfx multi --teammate-mode wt "작업"${RESET} ${DIM}(Windows Terminal split-pane)${RESET}`);
|
|
20
|
-
console.log(` ${WHITE}tfx multi --teammate-mode in-process "작업"${RESET} ${DIM}(mux 불필요)${RESET}\n`);
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
function printWorkerPreview(agents, subtasks) {
|
|
24
|
-
for (let index = 0; index < subtasks.length; index += 1) {
|
|
25
|
-
const preview = subtasks[index].length > 44 ? `${subtasks[index].slice(0, 44)}…` : subtasks[index];
|
|
26
|
-
console.log(` ${DIM}[${agents[index]}-${index + 1}] ${preview}${RESET}`);
|
|
27
|
-
}
|
|
28
|
-
console.log("");
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
function renderTmuxInstallHelp() {
|
|
32
|
-
console.log(`\n ${RED}${BOLD}tmux 미발견${RESET}\n`);
|
|
33
|
-
console.log(" 현재 선택한 모드는 tmux 기반 팀세션이 필요합니다.\n");
|
|
34
|
-
console.log(` 설치:\n WSL2: ${WHITE}wsl sudo apt install tmux${RESET}\n macOS: ${WHITE}brew install tmux${RESET}\n Linux: ${WHITE}apt install tmux${RESET}\n`);
|
|
35
|
-
console.log(` Windows에서는 WSL2를 권장합니다:\n 1. ${WHITE}wsl --install${RESET}\n 2. ${WHITE}wsl sudo apt install tmux${RESET}\n 3. ${WHITE}tfx multi "작업"${RESET}\n`);
|
|
36
|
-
}
|
|
37
|
-
|
|
38
|
-
export { parseTeamArgs };
|
|
39
|
-
|
|
40
|
-
export async function teamStart(args = []) {
|
|
41
|
-
const { agents, lead, layout, teammateMode, task: rawTask, assigns, autoAttach, progressive, timeoutSec, verbose, dashboard, mcpProfile } = parseTeamArgs(args);
|
|
42
|
-
// --assign 사용 시 task를 자동 생성
|
|
43
|
-
const task = rawTask || (assigns.length > 0 ? assigns.map(a => a.prompt).join(" + ") : "");
|
|
44
|
-
if (!task) return printStartUsage();
|
|
45
|
-
|
|
46
|
-
console.log(`\n ${AMBER}${BOLD}⬡ tfx multi${RESET}\n`);
|
|
47
|
-
|
|
48
|
-
// P1b: 워커 수 계산 — 단일 워커 headless에는 Hub 불필요
|
|
49
|
-
const workerCount = assigns.length > 0 ? assigns.length : agents.length;
|
|
50
|
-
const needsHub = workerCount >= 2 || teammateMode !== "headless";
|
|
51
|
-
|
|
52
|
-
let hub = null;
|
|
53
|
-
if (needsHub) {
|
|
54
|
-
hub = await getHubInfo();
|
|
55
|
-
if (!hub) {
|
|
56
|
-
process.stdout.write(" Hub 시작 중...");
|
|
57
|
-
try { hub = await startHubDaemon(); } catch (error) { if (error?.code === "HUB_SERVER_MISSING") fail("hub/server.mjs 없음 — hub 모듈이 설치되지 않음"); }
|
|
58
|
-
console.log(` ${hub ? `${GREEN}✓${RESET}` : `${RED}✗${RESET}`}`);
|
|
59
|
-
if (!hub) warn("Hub 시작 실패 — 수동으로 실행: tfx hub start");
|
|
60
|
-
} else ok(`Hub: ${DIM}${hub.url}${RESET}`);
|
|
61
|
-
} else {
|
|
62
|
-
ok(`Hub: ${DIM}건너뜀 (단일 워커 headless)${RESET}`);
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
const sessionId = `tfx-multi-${Date.now().toString(36).slice(-4)}${Math.random().toString(36).slice(2, 6)}`;
|
|
66
|
-
const subtasks = decomposeTask(task, agents.length);
|
|
67
|
-
const hubUrl = hub?.url || getDefaultHubUrl();
|
|
68
|
-
let effectiveMode = teammateMode;
|
|
69
|
-
if (effectiveMode === "wt" && !hasWindowsTerminal()) { warn("wt.exe 미발견 — in-process 모드로 자동 fallback"); effectiveMode = "in-process"; }
|
|
70
|
-
if (effectiveMode === "wt" && !hasWindowsTerminalSession()) { warn("WT_SESSION 미감지(Windows Terminal 외부) — in-process 모드로 자동 fallback"); effectiveMode = "in-process"; }
|
|
71
|
-
|
|
72
|
-
console.log(` 세션: ${WHITE}${sessionId}${RESET}`);
|
|
73
|
-
console.log(` 모드: ${effectiveMode}`);
|
|
74
|
-
console.log(` 리드: ${AMBER}${lead}${RESET}`);
|
|
75
|
-
console.log(` 워커: ${agents.map((agent) => `${AMBER}${agent}${RESET}`).join(", ")}`);
|
|
76
|
-
printWorkerPreview(agents, subtasks);
|
|
77
|
-
|
|
78
|
-
if (effectiveMode === "tmux") {
|
|
79
|
-
try { ensureTmuxOrExit(); } catch { return renderTmuxInstallHelp(); }
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
const state = effectiveMode === "in-process"
|
|
83
|
-
? await startInProcessTeam({ sessionId, task, lead, agents, subtasks, hubUrl })
|
|
84
|
-
: effectiveMode === "headless"
|
|
85
|
-
? await startHeadlessTeam({ sessionId, task, lead, agents, subtasks, layout, assigns, autoAttach, progressive, timeoutSec, verbose, dashboard, mcpProfile })
|
|
86
|
-
: effectiveMode === "wt"
|
|
87
|
-
? await startWtTeam({ sessionId, task, lead, agents, subtasks, layout, hubUrl })
|
|
88
|
-
: await startMuxTeam({ sessionId, task, lead, agents, subtasks, layout, hubUrl, teammateMode: effectiveMode });
|
|
89
|
-
|
|
90
|
-
if (!state) return fail("in-process supervisor 시작 실패");
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
if (
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
console.log(` ${DIM}
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
console.log(` ${DIM}
|
|
101
|
-
}
|
|
102
|
-
}
|
|
1
|
+
import { decomposeTask } from "../../../orchestrator.mjs";
|
|
2
|
+
import { hasWindowsTerminal, hasWindowsTerminalSession } from "../../../session.mjs";
|
|
3
|
+
import { AMBER, BOLD, DIM, GREEN, RED, RESET, WHITE } from "../../../shared.mjs";
|
|
4
|
+
import { getDefaultHubUrl, getHubInfo, startHubDaemon } from "../../services/hub-client.mjs";
|
|
5
|
+
import { ensureTmuxOrExit } from "../../services/runtime-mode.mjs";
|
|
6
|
+
import { saveTeamState } from "../../services/state-store.mjs";
|
|
7
|
+
import { fail, ok, warn } from "../../render.mjs";
|
|
8
|
+
import { parseTeamArgs } from "./parse-args.mjs";
|
|
9
|
+
import { startInProcessTeam } from "./start-in-process.mjs";
|
|
10
|
+
import { startMuxTeam } from "./start-mux.mjs";
|
|
11
|
+
import { startHeadlessTeam } from "./start-headless.mjs";
|
|
12
|
+
import { startWtTeam } from "./start-wt.mjs";
|
|
13
|
+
|
|
14
|
+
function printStartUsage() {
|
|
15
|
+
console.log(`\n ${AMBER}${BOLD}⬡ tfx multi${RESET}\n`);
|
|
16
|
+
console.log(` 사용법: ${WHITE}tfx multi "작업 설명"${RESET}`);
|
|
17
|
+
console.log(` ${WHITE}tfx multi --agents codex,gemini --lead claude "작업"${RESET}`);
|
|
18
|
+
console.log(` ${WHITE}tfx multi --teammate-mode headless "작업"${RESET} ${DIM}(psmux 헤드리스, 기본)${RESET}`);
|
|
19
|
+
console.log(` ${WHITE}tfx multi --teammate-mode wt "작업"${RESET} ${DIM}(Windows Terminal split-pane)${RESET}`);
|
|
20
|
+
console.log(` ${WHITE}tfx multi --teammate-mode in-process "작업"${RESET} ${DIM}(mux 불필요)${RESET}\n`);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function printWorkerPreview(agents, subtasks) {
|
|
24
|
+
for (let index = 0; index < subtasks.length; index += 1) {
|
|
25
|
+
const preview = subtasks[index].length > 44 ? `${subtasks[index].slice(0, 44)}…` : subtasks[index];
|
|
26
|
+
console.log(` ${DIM}[${agents[index]}-${index + 1}] ${preview}${RESET}`);
|
|
27
|
+
}
|
|
28
|
+
console.log("");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function renderTmuxInstallHelp() {
|
|
32
|
+
console.log(`\n ${RED}${BOLD}tmux 미발견${RESET}\n`);
|
|
33
|
+
console.log(" 현재 선택한 모드는 tmux 기반 팀세션이 필요합니다.\n");
|
|
34
|
+
console.log(` 설치:\n WSL2: ${WHITE}wsl sudo apt install tmux${RESET}\n macOS: ${WHITE}brew install tmux${RESET}\n Linux: ${WHITE}apt install tmux${RESET}\n`);
|
|
35
|
+
console.log(` Windows에서는 WSL2를 권장합니다:\n 1. ${WHITE}wsl --install${RESET}\n 2. ${WHITE}wsl sudo apt install tmux${RESET}\n 3. ${WHITE}tfx multi "작업"${RESET}\n`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export { parseTeamArgs };
|
|
39
|
+
|
|
40
|
+
export async function teamStart(args = []) {
|
|
41
|
+
const { agents, lead, layout, teammateMode, task: rawTask, assigns, autoAttach, progressive, timeoutSec, verbose, dashboard, mcpProfile } = parseTeamArgs(args);
|
|
42
|
+
// --assign 사용 시 task를 자동 생성
|
|
43
|
+
const task = rawTask || (assigns.length > 0 ? assigns.map(a => a.prompt).join(" + ") : "");
|
|
44
|
+
if (!task) return printStartUsage();
|
|
45
|
+
|
|
46
|
+
console.log(`\n ${AMBER}${BOLD}⬡ tfx multi${RESET}\n`);
|
|
47
|
+
|
|
48
|
+
// P1b: 워커 수 계산 — 단일 워커 headless에는 Hub 불필요
|
|
49
|
+
const workerCount = assigns.length > 0 ? assigns.length : agents.length;
|
|
50
|
+
const needsHub = workerCount >= 2 || teammateMode !== "headless";
|
|
51
|
+
|
|
52
|
+
let hub = null;
|
|
53
|
+
if (needsHub) {
|
|
54
|
+
hub = await getHubInfo();
|
|
55
|
+
if (!hub) {
|
|
56
|
+
process.stdout.write(" Hub 시작 중...");
|
|
57
|
+
try { hub = await startHubDaemon(); } catch (error) { if (error?.code === "HUB_SERVER_MISSING") fail("hub/server.mjs 없음 — hub 모듈이 설치되지 않음"); }
|
|
58
|
+
console.log(` ${hub ? `${GREEN}✓${RESET}` : `${RED}✗${RESET}`}`);
|
|
59
|
+
if (!hub) warn("Hub 시작 실패 — 수동으로 실행: tfx hub start");
|
|
60
|
+
} else ok(`Hub: ${DIM}${hub.url}${RESET}`);
|
|
61
|
+
} else {
|
|
62
|
+
ok(`Hub: ${DIM}건너뜀 (단일 워커 headless)${RESET}`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const sessionId = `tfx-multi-${Date.now().toString(36).slice(-4)}${Math.random().toString(36).slice(2, 6)}`;
|
|
66
|
+
const subtasks = decomposeTask(task, agents.length);
|
|
67
|
+
const hubUrl = hub?.url || getDefaultHubUrl();
|
|
68
|
+
let effectiveMode = teammateMode;
|
|
69
|
+
if (effectiveMode === "wt" && !hasWindowsTerminal()) { warn("wt.exe 미발견 — in-process 모드로 자동 fallback"); effectiveMode = "in-process"; }
|
|
70
|
+
if (effectiveMode === "wt" && !hasWindowsTerminalSession()) { warn("WT_SESSION 미감지(Windows Terminal 외부) — in-process 모드로 자동 fallback"); effectiveMode = "in-process"; }
|
|
71
|
+
|
|
72
|
+
console.log(` 세션: ${WHITE}${sessionId}${RESET}`);
|
|
73
|
+
console.log(` 모드: ${effectiveMode}`);
|
|
74
|
+
console.log(` 리드: ${AMBER}${lead}${RESET}`);
|
|
75
|
+
console.log(` 워커: ${agents.map((agent) => `${AMBER}${agent}${RESET}`).join(", ")}`);
|
|
76
|
+
printWorkerPreview(agents, subtasks);
|
|
77
|
+
|
|
78
|
+
if (effectiveMode === "tmux") {
|
|
79
|
+
try { ensureTmuxOrExit(); } catch { return renderTmuxInstallHelp(); }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const state = effectiveMode === "in-process"
|
|
83
|
+
? await startInProcessTeam({ sessionId, task, lead, agents, subtasks, hubUrl })
|
|
84
|
+
: effectiveMode === "headless"
|
|
85
|
+
? await startHeadlessTeam({ sessionId, task, lead, agents, subtasks, layout, assigns, autoAttach, progressive, timeoutSec, verbose, dashboard, mcpProfile })
|
|
86
|
+
: effectiveMode === "wt"
|
|
87
|
+
? await startWtTeam({ sessionId, task, lead, agents, subtasks, layout, hubUrl })
|
|
88
|
+
: await startMuxTeam({ sessionId, task, lead, agents, subtasks, layout, hubUrl, teammateMode: effectiveMode });
|
|
89
|
+
|
|
90
|
+
if (!state) return fail("in-process supervisor 시작 실패");
|
|
91
|
+
state.sessionId = sessionId;
|
|
92
|
+
saveTeamState(state, sessionId);
|
|
93
|
+
if (typeof state.postSave === "function") state.postSave();
|
|
94
|
+
if (effectiveMode === "in-process") {
|
|
95
|
+
ok("네이티브 in-process 팀 시작 완료");
|
|
96
|
+
console.log(` ${DIM}tmux 없이 실행됨 (직접 CLI 프로세스)${RESET}`);
|
|
97
|
+
console.log(` ${DIM}제어: tfx multi send/control/tasks/status${RESET}\n`);
|
|
98
|
+
} else if (effectiveMode === "wt") {
|
|
99
|
+
ok("Windows Terminal wt 팀 시작 완료");
|
|
100
|
+
console.log(` ${DIM}현재 pane 기준으로 ${state.layout} 분할 생성됨${RESET}`);
|
|
101
|
+
console.log(` ${DIM}wt 모드는 자동 프롬프트 주입/Hub direct 제어(send/control)가 제한됩니다.${RESET}\n`);
|
|
102
|
+
}
|
|
103
|
+
}
|
package/hub/team/headless.mjs
CHANGED
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
psmuxExec,
|
|
21
21
|
} from "./psmux.mjs";
|
|
22
22
|
import { HANDOFF_INSTRUCTION_SHORT, processHandoff } from "./handoff.mjs";
|
|
23
|
+
import { getBackend } from "./backend.mjs";
|
|
23
24
|
|
|
24
25
|
const RESULT_DIR = join(tmpdir(), "tfx-headless");
|
|
25
26
|
|
|
@@ -93,16 +94,9 @@ export function buildHeadlessCommand(cli, prompt, resultFile, opts = {}) {
|
|
|
93
94
|
|
|
94
95
|
const cls = "Clear-Host; ";
|
|
95
96
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
case "gemini":
|
|
100
|
-
return `${cls}gemini -p (Get-Content -Raw '${promptFile}') -o text > '${resultFile}' 2>'${resultFile}.err'`;
|
|
101
|
-
case "claude":
|
|
102
|
-
return `${cls}claude -p (Get-Content -Raw '${promptFile}') --output-format text > '${resultFile}' 2>&1`;
|
|
103
|
-
default:
|
|
104
|
-
throw new Error(`지원하지 않는 CLI: ${resolvedCli} (원본: ${cli})`);
|
|
105
|
-
}
|
|
97
|
+
const backend = getBackend(resolvedCli);
|
|
98
|
+
const promptExpr = `(Get-Content -Raw '${promptFile}')`;
|
|
99
|
+
return `${cls}${backend.buildArgs(promptExpr, resultFile, opts)}`;
|
|
106
100
|
}
|
|
107
101
|
|
|
108
102
|
/**
|