triflux 7.2.1 → 7.3.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.
@@ -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
+ }
@@ -2,3 +2,4 @@
2
2
 
3
3
  export { CRITERIA, runConfidenceCheck } from './confidence.mjs';
4
4
  export { RED_FLAGS, QUESTIONS, runSelfCheck } from './selfcheck.mjs';
5
+ export { STAGE_THRESHOLDS, evaluateQualityBranch, evaluateConsensus } from './consensus.mjs';
@@ -0,0 +1,154 @@
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
+ weight: 0.85,
20
+ },
21
+ // 중간 복잡도 (0.4-0.7)
22
+ medium: {
23
+ keywords: [
24
+ 'implement', 'integrate', 'api', 'endpoint', 'middleware',
25
+ 'validation', 'error handling', 'testing', 'debug', 'fix bug',
26
+ 'configuration', 'deploy', 'ci/cd', 'docker', 'container',
27
+ 'cache', 'queue', 'webhook', 'notification', 'logging',
28
+ ],
29
+ weight: 0.55,
30
+ },
31
+ // 낮은 복잡도 (0.1-0.4)
32
+ low: {
33
+ keywords: [
34
+ 'readme', 'comment', 'typo', 'rename', 'format', 'lint',
35
+ 'update version', 'bump', 'add dependency', 'install',
36
+ 'simple', 'trivial', 'minor', 'small change', 'one-liner',
37
+ ],
38
+ weight: 0.2,
39
+ },
40
+ };
41
+
42
+ /**
43
+ * 어휘 복잡도 계산 (20%)
44
+ * 고유 단어 비율 + 평균 단어 길이 기반
45
+ * @param {string[]} words
46
+ * @returns {number} 0-1
47
+ */
48
+ function lexicalComplexity(words) {
49
+ if (words.length === 0) return 0;
50
+ const unique = new Set(words);
51
+ const typeTokenRatio = unique.size / words.length;
52
+ const avgWordLen = words.reduce((sum, w) => sum + w.length, 0) / words.length;
53
+ // 긴 단어(기술 용어)가 많을수록 복잡
54
+ const lenScore = Math.min(avgWordLen / 10, 1);
55
+ return typeTokenRatio * 0.5 + lenScore * 0.5;
56
+ }
57
+
58
+ /**
59
+ * 시맨틱 깊이 계산 (35%)
60
+ * 키워드 사전 매칭 기반
61
+ * @param {string} text
62
+ * @returns {number} 0-1
63
+ */
64
+ function semanticDepth(text) {
65
+ const lower = text.toLowerCase();
66
+ let maxWeight = 0;
67
+ let matchCount = 0;
68
+
69
+ for (const [, category] of Object.entries(COMPLEXITY_INDICATORS)) {
70
+ for (const kw of category.keywords) {
71
+ if (lower.includes(kw)) {
72
+ matchCount++;
73
+ if (category.weight > maxWeight) maxWeight = category.weight;
74
+ }
75
+ }
76
+ }
77
+ // 매칭 키워드 수와 최고 가중치 조합
78
+ const countScore = Math.min(matchCount / 5, 1);
79
+ return maxWeight * 0.6 + countScore * 0.4;
80
+ }
81
+
82
+ /**
83
+ * 작업 범위 계산 (25%)
84
+ * 문장 수, 줄 수, 파일/경로 참조 기반
85
+ * @param {string} text
86
+ * @returns {number} 0-1
87
+ */
88
+ function taskScope(text) {
89
+ const lines = text.split('\n').filter((l) => l.trim().length > 0);
90
+ const sentences = text.split(/[.!?]+/).filter((s) => s.trim().length > 0);
91
+ const fileRefs = (text.match(/[\w\-/]+\.\w{1,5}/g) || []).length;
92
+
93
+ const lineScore = Math.min(lines.length / 20, 1);
94
+ const sentenceScore = Math.min(sentences.length / 10, 1);
95
+ const fileScore = Math.min(fileRefs / 5, 1);
96
+
97
+ return lineScore * 0.3 + sentenceScore * 0.3 + fileScore * 0.4;
98
+ }
99
+
100
+ /**
101
+ * 불확실성 계산 (20%)
102
+ * 모호한 표현, 질문, 조건문 기반
103
+ * @param {string} text
104
+ * @returns {number} 0-1
105
+ */
106
+ function uncertainty(text) {
107
+ const lower = text.toLowerCase();
108
+ const uncertainWords = [
109
+ 'maybe', 'perhaps', 'might', 'could', 'possibly', 'unclear',
110
+ 'not sure', 'investigate', 'explore', 'research', 'try',
111
+ 'consider', 'evaluate', 'assess', 'determine', 'figure out',
112
+ ];
113
+ let count = 0;
114
+ for (const w of uncertainWords) {
115
+ if (lower.includes(w)) count++;
116
+ }
117
+ const questions = (text.match(/\?/g) || []).length;
118
+ const wordScore = Math.min(count / 4, 1);
119
+ const questionScore = Math.min(questions / 3, 1);
120
+ return wordScore * 0.6 + questionScore * 0.4;
121
+ }
122
+
123
+ /**
124
+ * 작업 복잡도 스코어링
125
+ * @param {string} taskDescription — 작업 설명 텍스트
126
+ * @returns {{ score: number, breakdown: { lexical: number, semantic: number, scope: number, uncertainty: number } }}
127
+ */
128
+ export function scoreComplexity(taskDescription) {
129
+ if (!taskDescription || typeof taskDescription !== 'string') {
130
+ return { score: 0, breakdown: { lexical: 0, semantic: 0, scope: 0, uncertainty: 0 } };
131
+ }
132
+
133
+ const words = taskDescription.toLowerCase().split(/\s+/).filter((w) => w.length > 0);
134
+ const lexical = lexicalComplexity(words);
135
+ const semantic = semanticDepth(taskDescription);
136
+ const scope = taskScope(taskDescription);
137
+ const uncertain = uncertainty(taskDescription);
138
+
139
+ // 가중 합산: 어휘(20%) + 시맨틱(35%) + 범위(25%) + 불확실성(20%)
140
+ const score = Math.min(
141
+ lexical * 0.20 + semantic * 0.35 + scope * 0.25 + uncertain * 0.20,
142
+ 1,
143
+ );
144
+
145
+ return {
146
+ score: Math.round(score * 1000) / 1000,
147
+ breakdown: {
148
+ lexical: Math.round(lexical * 1000) / 1000,
149
+ semantic: Math.round(semantic * 1000) / 1000,
150
+ scope: Math.round(scope * 1000) / 1000,
151
+ uncertainty: Math.round(uncertain * 1000) / 1000,
152
+ },
153
+ };
154
+ }
@@ -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,319 @@
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
+ /** 특성 벡터 키워드 (32차원, 에이전트 타입 기반) */
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
+ ];
36
+
37
+ /**
38
+ * 텍스트에서 32차원 특성 벡터 추출
39
+ * @param {string} text
40
+ * @returns {number[]} 32-dim binary feature vector
41
+ */
42
+ function extractFeatures(text) {
43
+ const lower = text.toLowerCase();
44
+ return FEATURE_KEYWORDS.map((kw) => (lower.includes(kw) ? 1 : 0));
45
+ }
46
+
47
+ /**
48
+ * 특성 벡터를 상태 키로 변환 (해시 기반)
49
+ * @param {number[]} features
50
+ * @returns {string}
51
+ */
52
+ function stateKey(features) {
53
+ const hash = createHash('sha256').update(features.join(',')).digest('hex');
54
+ return hash.slice(0, 16);
55
+ }
56
+
57
+ /**
58
+ * LRU 캐시 (예측 결과 캐싱)
59
+ */
60
+ class LRUCache {
61
+ /** @param {number} maxSize @param {number} ttlMs */
62
+ constructor(maxSize = 256, ttlMs = 5 * 60 * 1000) {
63
+ this._maxSize = maxSize;
64
+ this._ttlMs = ttlMs;
65
+ /** @type {Map<string, { value: *, ts: number }>} */
66
+ this._cache = new Map();
67
+ }
68
+
69
+ get(key) {
70
+ const entry = this._cache.get(key);
71
+ if (!entry) return undefined;
72
+ if (Date.now() - entry.ts > this._ttlMs) {
73
+ this._cache.delete(key);
74
+ return undefined;
75
+ }
76
+ // LRU: 다시 삽입하여 순서 갱신
77
+ this._cache.delete(key);
78
+ this._cache.set(key, entry);
79
+ return entry.value;
80
+ }
81
+
82
+ set(key, value) {
83
+ if (this._cache.has(key)) this._cache.delete(key);
84
+ this._cache.set(key, { value, ts: Date.now() });
85
+ // 초과 시 가장 오래된 항목 제거
86
+ if (this._cache.size > this._maxSize) {
87
+ const oldest = this._cache.keys().next().value;
88
+ this._cache.delete(oldest);
89
+ }
90
+ }
91
+
92
+ clear() {
93
+ this._cache.clear();
94
+ }
95
+ }
96
+
97
+ /**
98
+ * Q-Learning 라우터
99
+ * 테이블 기반 Q-Learning으로 작업→CLI 매핑을 학습한다.
100
+ */
101
+ export class QLearningRouter {
102
+ /**
103
+ * @param {object} [opts]
104
+ * @param {number} [opts.learningRate=0.1] — 학습률 (alpha)
105
+ * @param {number} [opts.discountFactor=0.9] — 할인율 (gamma)
106
+ * @param {number} [opts.epsilon=0.3] — 탐색 확률 (epsilon-greedy)
107
+ * @param {number} [opts.epsilonDecay=0.995] — 엡실론 감쇠율
108
+ * @param {number} [opts.epsilonMin=0.05] — 최소 엡실론
109
+ * @param {number} [opts.minConfidence=0.6] — 최소 신뢰도 (이하면 폴백)
110
+ * @param {string} [opts.modelPath] — Q-table 영속화 경로
111
+ */
112
+ constructor(opts = {}) {
113
+ this._lr = opts.learningRate ?? 0.1;
114
+ this._gamma = opts.discountFactor ?? 0.9;
115
+ this._epsilon = opts.epsilon ?? 0.3;
116
+ this._epsilonDecay = opts.epsilonDecay ?? 0.995;
117
+ this._epsilonMin = opts.epsilonMin ?? 0.05;
118
+ this._minConfidence = opts.minConfidence ?? 0.6;
119
+ this._modelPath = opts.modelPath ?? join(homedir(), '.omc', 'routing-model.json');
120
+
121
+ /** @type {Map<string, Map<string, number>>} state -> (action -> Q-value) */
122
+ this._qTable = new Map();
123
+
124
+ /** @type {Map<string, number>} state -> visit count */
125
+ this._visitCounts = new Map();
126
+
127
+ /** 총 업데이트 횟수 */
128
+ this._totalUpdates = 0;
129
+
130
+ /** 예측 캐시 */
131
+ this._cache = new LRUCache(256, 5 * 60 * 1000);
132
+ }
133
+
134
+ /**
135
+ * 상태에 대한 Q-values 조회 (없으면 초기화)
136
+ * @param {string} state
137
+ * @returns {Map<string, number>}
138
+ */
139
+ _getQValues(state) {
140
+ if (!this._qTable.has(state)) {
141
+ const qValues = new Map();
142
+ for (const action of ACTIONS) {
143
+ qValues.set(action, 0);
144
+ }
145
+ this._qTable.set(state, qValues);
146
+ }
147
+ return this._qTable.get(state);
148
+ }
149
+
150
+ /**
151
+ * 작업 설명으로부터 최적 CLI 타입 예측
152
+ * @param {string} taskDescription
153
+ * @returns {{ action: string, confidence: number, exploration: boolean, complexity: number }}
154
+ */
155
+ predict(taskDescription) {
156
+ const features = extractFeatures(taskDescription);
157
+ const state = stateKey(features);
158
+
159
+ // 캐시 확인
160
+ const cached = this._cache.get(state);
161
+ if (cached) return cached;
162
+
163
+ const qValues = this._getQValues(state);
164
+ const visits = this._visitCounts.get(state) || 0;
165
+
166
+ // 엡실론-그리디: 탐색 vs 활용
167
+ const isExploration = Math.random() < this._epsilon;
168
+
169
+ let action;
170
+ if (isExploration) {
171
+ // 무작위 탐색
172
+ action = ACTIONS[Math.floor(Math.random() * ACTIONS.length)];
173
+ } else {
174
+ // 최적 액션 선택 (최대 Q-value)
175
+ let maxQ = -Infinity;
176
+ action = ACTIONS[0];
177
+ for (const [a, q] of qValues) {
178
+ if (q > maxQ) {
179
+ maxQ = q;
180
+ action = a;
181
+ }
182
+ }
183
+ }
184
+
185
+ // 신뢰도 계산: 방문 횟수 기반 (최소 10회 이상이면 안정)
186
+ const confidence = visits >= 10
187
+ ? Math.min(visits / 50, 1)
188
+ : visits / 10;
189
+
190
+ const { score: complexity } = scoreComplexity(taskDescription);
191
+
192
+ const result = { action, confidence, exploration: isExploration, complexity };
193
+ // 탐색(랜덤) 결과는 캐싱하지 않음 — 매번 새로운 랜덤 액션 생성
194
+ if (!isExploration) this._cache.set(state, result);
195
+ return result;
196
+ }
197
+
198
+ /**
199
+ * Q-table 업데이트 (보상 피드백)
200
+ * @param {string} taskDescription — 작업 설명
201
+ * @param {string} action — 수행한 액션 (CLI 타입)
202
+ * @param {number} reward — 보상 (-1 ~ 1)
203
+ */
204
+ update(taskDescription, action, reward) {
205
+ if (!ACTIONS.includes(action)) return;
206
+
207
+ const features = extractFeatures(taskDescription);
208
+ const state = stateKey(features);
209
+ const qValues = this._getQValues(state);
210
+ const oldQ = qValues.get(action) || 0;
211
+
212
+ // 최대 미래 Q-value (단일 상태이므로 현재 상태의 max)
213
+ let maxFutureQ = -Infinity;
214
+ for (const [, q] of qValues) {
215
+ if (q > maxFutureQ) maxFutureQ = q;
216
+ }
217
+
218
+ // Q-Learning 업데이트: Q(s,a) = Q(s,a) + lr * (reward + gamma * max_Q(s') - Q(s,a))
219
+ const newQ = oldQ + this._lr * (reward + this._gamma * maxFutureQ - oldQ);
220
+ qValues.set(action, newQ);
221
+
222
+ // 방문 횟수 증가
223
+ this._visitCounts.set(state, (this._visitCounts.get(state) || 0) + 1);
224
+ this._totalUpdates++;
225
+
226
+ // 엡실론 감쇠
227
+ this._epsilon = Math.max(this._epsilonMin, this._epsilon * this._epsilonDecay);
228
+
229
+ // 캐시 무효화 (해당 상태)
230
+ this._cache.clear();
231
+ }
232
+
233
+ /**
234
+ * Q-table을 JSON 파일로 영속화
235
+ */
236
+ save() {
237
+ const dir = join(this._modelPath, '..');
238
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
239
+
240
+ const data = {
241
+ version: 1,
242
+ epsilon: this._epsilon,
243
+ totalUpdates: this._totalUpdates,
244
+ qTable: {},
245
+ visitCounts: {},
246
+ };
247
+
248
+ for (const [state, qValues] of this._qTable) {
249
+ data.qTable[state] = Object.fromEntries(qValues);
250
+ }
251
+ for (const [state, count] of this._visitCounts) {
252
+ data.visitCounts[state] = count;
253
+ }
254
+
255
+ writeFileSync(this._modelPath, JSON.stringify(data, null, 2), 'utf8');
256
+ }
257
+
258
+ /**
259
+ * 영속화된 Q-table 로드
260
+ * @returns {boolean} 로드 성공 여부
261
+ */
262
+ load() {
263
+ if (!existsSync(this._modelPath)) return false;
264
+
265
+ try {
266
+ const raw = readFileSync(this._modelPath, 'utf8');
267
+ const data = JSON.parse(raw);
268
+ if (data.version !== 1) return false;
269
+
270
+ this._epsilon = data.epsilon ?? this._epsilon;
271
+ this._totalUpdates = data.totalUpdates ?? 0;
272
+ this._qTable = new Map();
273
+ this._visitCounts = new Map();
274
+
275
+ for (const [state, qObj] of Object.entries(data.qTable || {})) {
276
+ const qValues = new Map();
277
+ for (const [action, q] of Object.entries(qObj)) {
278
+ if (ACTIONS.includes(action)) qValues.set(action, q);
279
+ }
280
+ this._qTable.set(state, qValues);
281
+ }
282
+ for (const [state, count] of Object.entries(data.visitCounts || {})) {
283
+ this._visitCounts.set(state, count);
284
+ }
285
+
286
+ this._cache.clear();
287
+ return true;
288
+ } catch {
289
+ return false;
290
+ }
291
+ }
292
+
293
+ /**
294
+ * agent-map.json 폴백 조회
295
+ * @param {string} agentType — 에이전트 역할명
296
+ * @returns {string} CLI 타입
297
+ */
298
+ static fallback(agentType) {
299
+ return AGENT_MAP[agentType] || agentType;
300
+ }
301
+
302
+ /** 현재 엡실론 값 */
303
+ get epsilon() {
304
+ return this._epsilon;
305
+ }
306
+
307
+ /** 총 업데이트 횟수 */
308
+ get totalUpdates() {
309
+ return this._totalUpdates;
310
+ }
311
+
312
+ /** Q-table 상태 수 */
313
+ get stateCount() {
314
+ return this._qTable.size;
315
+ }
316
+ }
317
+
318
+ // 모듈 레벨 export
319
+ 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
+ }
@@ -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
- switch (resolvedCli) {
97
- case "codex":
98
- return `${cls}codex exec (Get-Content -Raw '${promptFile}') -o '${resultFile}' --color never`;
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
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "triflux",
3
- "version": "7.2.1",
3
+ "version": "7.3.0",
4
4
  "description": "CLI-first multi-model orchestrator for Claude Code — route tasks to Codex, Gemini, and Claude",
5
5
  "type": "module",
6
6
  "bin": {
package/scripts/setup.mjs CHANGED
@@ -171,6 +171,11 @@ const SYNC_MAP = [
171
171
  dst: join(CLAUDE_DIR, "scripts", "lib", "keyword-rules.mjs"),
172
172
  label: "lib/keyword-rules.mjs",
173
173
  },
174
+ {
175
+ src: join(PLUGIN_ROOT, "hub", "team", "agent-map.json"),
176
+ dst: join(CLAUDE_DIR, "hub", "team", "agent-map.json"),
177
+ label: "hub/team/agent-map.json",
178
+ },
174
179
  {
175
180
  src: join(PLUGIN_ROOT, "scripts", "headless-guard.mjs"),
176
181
  dst: join(CLAUDE_DIR, "scripts", "headless-guard.mjs"),
@@ -574,6 +574,14 @@ route_agent() {
574
574
  local codex_base="--dangerously-bypass-approvals-and-sandbox --skip-git-repo-check"
575
575
  local map_file
576
576
  map_file="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/../hub/team/agent-map.json"
577
+ # ── breadcrumb 폴백 (synced 환경: ~/.claude/scripts/) ──
578
+ if [[ ! -f "$map_file" && -n "$TFX_PKG_ROOT" ]]; then
579
+ map_file="$TFX_PKG_ROOT/hub/team/agent-map.json"
580
+ fi
581
+ if [[ ! -f "$map_file" ]]; then
582
+ echo "ERROR: agent-map.json 미발견 (경로: $map_file, TFX_PKG_ROOT=${TFX_PKG_ROOT:-unset})" >&2
583
+ exit 1
584
+ fi
577
585
 
578
586
  # ── CLI_TYPE: 단일 소스 (agent-map.json) ──
579
587
  local _raw_type