@triflux/core 10.0.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/hooks/agent-route-guard.mjs +109 -0
  2. package/hooks/cross-review-tracker.mjs +122 -0
  3. package/hooks/error-context.mjs +148 -0
  4. package/hooks/hook-manager.mjs +352 -0
  5. package/hooks/hook-orchestrator.mjs +312 -0
  6. package/hooks/hook-registry.json +213 -0
  7. package/hooks/hooks.json +89 -0
  8. package/hooks/keyword-rules.json +581 -0
  9. package/hooks/lib/resolve-root.mjs +59 -0
  10. package/hooks/mcp-config-watcher.mjs +85 -0
  11. package/hooks/pipeline-stop.mjs +76 -0
  12. package/hooks/safety-guard.mjs +106 -0
  13. package/hooks/subagent-verifier.mjs +80 -0
  14. package/hub/assign-callbacks.mjs +133 -0
  15. package/hub/bridge.mjs +799 -0
  16. package/hub/cli-adapter-base.mjs +192 -0
  17. package/hub/codex-adapter.mjs +190 -0
  18. package/hub/codex-compat.mjs +78 -0
  19. package/hub/codex-preflight.mjs +147 -0
  20. package/hub/delegator/contracts.mjs +37 -0
  21. package/hub/delegator/index.mjs +14 -0
  22. package/hub/delegator/schema/delegator-tools.schema.json +250 -0
  23. package/hub/delegator/service.mjs +307 -0
  24. package/hub/delegator/tool-definitions.mjs +35 -0
  25. package/hub/fullcycle.mjs +96 -0
  26. package/hub/gemini-adapter.mjs +179 -0
  27. package/hub/hitl.mjs +143 -0
  28. package/hub/intent.mjs +193 -0
  29. package/hub/lib/process-utils.mjs +361 -0
  30. package/hub/middleware/request-logger.mjs +81 -0
  31. package/hub/paths.mjs +30 -0
  32. package/hub/pipeline/gates/confidence.mjs +56 -0
  33. package/hub/pipeline/gates/consensus.mjs +94 -0
  34. package/hub/pipeline/gates/index.mjs +5 -0
  35. package/hub/pipeline/gates/selfcheck.mjs +82 -0
  36. package/hub/pipeline/index.mjs +318 -0
  37. package/hub/pipeline/state.mjs +191 -0
  38. package/hub/pipeline/transitions.mjs +124 -0
  39. package/hub/platform.mjs +225 -0
  40. package/hub/quality/deslop.mjs +253 -0
  41. package/hub/reflexion.mjs +372 -0
  42. package/hub/research.mjs +146 -0
  43. package/hub/router.mjs +791 -0
  44. package/hub/routing/complexity.mjs +166 -0
  45. package/hub/routing/index.mjs +117 -0
  46. package/hub/routing/q-learning.mjs +336 -0
  47. package/hub/session-fingerprint.mjs +352 -0
  48. package/hub/state.mjs +245 -0
  49. package/hub/team-bridge.mjs +25 -0
  50. package/hub/token-mode.mjs +224 -0
  51. package/hub/workers/worker-utils.mjs +104 -0
  52. package/hud/colors.mjs +88 -0
  53. package/hud/constants.mjs +81 -0
  54. package/hud/hud-qos-status.mjs +206 -0
  55. package/hud/providers/claude.mjs +309 -0
  56. package/hud/providers/codex.mjs +151 -0
  57. package/hud/providers/gemini.mjs +320 -0
  58. package/hud/renderers.mjs +424 -0
  59. package/hud/terminal.mjs +140 -0
  60. package/hud/utils.mjs +287 -0
  61. package/package.json +31 -0
  62. package/scripts/lib/claudemd-manager.mjs +325 -0
  63. package/scripts/lib/context.mjs +67 -0
  64. package/scripts/lib/cross-review-utils.mjs +51 -0
  65. package/scripts/lib/env-probe.mjs +241 -0
  66. package/scripts/lib/gemini-profiles.mjs +85 -0
  67. package/scripts/lib/hook-utils.mjs +14 -0
  68. package/scripts/lib/keyword-rules.mjs +166 -0
  69. package/scripts/lib/logger.mjs +105 -0
  70. package/scripts/lib/mcp-filter.mjs +739 -0
  71. package/scripts/lib/mcp-guard-engine.mjs +940 -0
  72. package/scripts/lib/mcp-manifest.mjs +79 -0
  73. package/scripts/lib/mcp-server-catalog.mjs +118 -0
  74. package/scripts/lib/psmux-info.mjs +119 -0
  75. package/scripts/lib/remote-spawn-transfer.mjs +196 -0
@@ -0,0 +1,179 @@
1
+ // hub/gemini-adapter.mjs — Gemini CLI 방어 계층
2
+ // codex-adapter.mjs와 동일 패턴, cli-adapter-base 공통 인터페이스 사용
3
+
4
+ import { mkdirSync } from 'node:fs';
5
+ import { join } from 'node:path';
6
+ import { tmpdir } from 'node:os';
7
+
8
+ import { withRetry } from './workers/worker-utils.mjs';
9
+ import { whichCommandAsync } from './platform.mjs';
10
+ import {
11
+ createCircuitBreaker,
12
+ createResult,
13
+ appendWarnings,
14
+ normalizePathForShell,
15
+ shellQuote,
16
+ runProcess,
17
+ } from './cli-adapter-base.mjs';
18
+
19
+ const breaker = createCircuitBreaker();
20
+
21
+ // ── Gemini-specific stall inference ─────────────────────────────
22
+
23
+ function inferStallMode(stdout, stderr) {
24
+ const text = `${stdout}\n${stderr}`.toLowerCase();
25
+ if (/(unauthorized|forbidden|auth|login|token|credential|api.?key)/u.test(text)) return 'auth_stall';
26
+ if (/\bmcp\b|playwright|tavily|brave|sequential|server/u.test(text)) return 'mcp_stall';
27
+ return 'timeout';
28
+ }
29
+
30
+ // ── Preflight ───────────────────────────────────────────────────
31
+
32
+ async function runPreflight(opts = {}) {
33
+ const geminiPath = await whichCommandAsync('gemini');
34
+ if (!geminiPath) {
35
+ return {
36
+ geminiPath: null,
37
+ warnings: ['Gemini CLI not found. Install Gemini and ensure `gemini` is available on PATH.'],
38
+ excludeMcpServers: [],
39
+ ok: false,
40
+ };
41
+ }
42
+
43
+ const warnings = [];
44
+ const excludeMcpServers = [];
45
+
46
+ for (const name of Array.isArray(opts.mcpServers) ? opts.mcpServers : []) {
47
+ const server = String(name ?? '').trim();
48
+ if (!server) continue;
49
+ // Gemini MCP health는 best-effort: 실행 시점에 --allowed-mcp-server-names로 필터링
50
+ // 사전 probe는 수행하지 않음 (gemini가 자체적으로 graceful degrade)
51
+ }
52
+
53
+ return { geminiPath, warnings, excludeMcpServers, ok: true };
54
+ }
55
+
56
+ // ── Command building ────────────────────────────────────────────
57
+
58
+ function buildGeminiCommand(prompt, resultFile, opts = {}) {
59
+ const parts = ['gemini'];
60
+
61
+ if (opts.model) parts.push('--model', shellQuote(opts.model));
62
+ parts.push('--yolo');
63
+
64
+ const allowed = Array.isArray(opts.allowedMcpServers) ? opts.allowedMcpServers : [];
65
+ const excluded = Array.isArray(opts.excludeMcpServers) ? opts.excludeMcpServers : [];
66
+ const filtered = allowed.filter((name) => !excluded.includes(name));
67
+ if (filtered.length) {
68
+ parts.push('--allowed-mcp-server-names', ...filtered.map((n) => shellQuote(n)));
69
+ }
70
+
71
+ parts.push('--prompt', shellQuote(prompt));
72
+ parts.push('--output-format', 'text');
73
+
74
+ if (resultFile) {
75
+ return `${parts.join(' ')} > ${shellQuote(normalizePathForShell(resultFile))} 2>${shellQuote(normalizePathForShell(resultFile + '.err'))}`;
76
+ }
77
+
78
+ return parts.join(' ');
79
+ }
80
+
81
+ function buildAttempts(opts, preflight) {
82
+ const timeout = Number.isFinite(opts.timeout) ? opts.timeout : 900_000;
83
+ const base = {
84
+ timeout,
85
+ model: opts.model,
86
+ allowedMcpServers: Array.isArray(opts.mcpServers) ? [...opts.mcpServers] : [],
87
+ excludeMcpServers: [...(preflight.excludeMcpServers || [])],
88
+ };
89
+ if (opts.retryOnFail === false) return [base];
90
+ return [
91
+ base,
92
+ { ...base, timeout: timeout * 2, allowedMcpServers: [] },
93
+ ];
94
+ }
95
+
96
+ // ── Public: buildExecArgs ───────────────────────────────────────
97
+
98
+ export function buildExecArgs(opts = {}) {
99
+ const prompt = typeof opts.prompt === 'string' ? opts.prompt : '';
100
+ return buildGeminiCommand(prompt, opts.resultFile || null, {
101
+ model: opts.model,
102
+ allowedMcpServers: opts.mcpServers,
103
+ });
104
+ }
105
+
106
+ // ── Execution ───────────────────────────────────────────────────
107
+
108
+ async function runGemini(prompt, workdir, preflight, attempt) {
109
+ const dir = join(tmpdir(), 'triflux-gemini-exec');
110
+ mkdirSync(dir, { recursive: true });
111
+ const resultFile = join(dir, `gemini-${Date.now()}-${Math.random().toString(36).slice(2)}.txt`);
112
+ const command = buildGeminiCommand(prompt, resultFile, {
113
+ model: attempt.model,
114
+ allowedMcpServers: attempt.allowedMcpServers,
115
+ excludeMcpServers: attempt.excludeMcpServers,
116
+ });
117
+ return runProcess(command, workdir, attempt.timeout, { resultFile, inferStallMode });
118
+ }
119
+
120
+ // ── Public API ──────────────────────────────────────────────────
121
+
122
+ export function getCircuitState(now) {
123
+ return breaker.getState(now);
124
+ }
125
+
126
+ export async function execute(opts = {}) {
127
+ const entry = breaker.canExecute();
128
+ if (!entry.allowed) {
129
+ return createResult(false, { fellBack: true, failureMode: 'circuit_open' });
130
+ }
131
+
132
+ const preflight = await runPreflight({ mcpServers: opts.mcpServers });
133
+ if (!preflight.ok) {
134
+ breaker.clearTrial();
135
+ breaker.recordFailure(entry.halfOpen);
136
+ return createResult(false, {
137
+ stderr: appendWarnings('', preflight.warnings),
138
+ fellBack: opts.fallbackToClaude !== false,
139
+ failureMode: 'crash',
140
+ });
141
+ }
142
+
143
+ const attempts = buildAttempts(opts, preflight);
144
+ let attemptIndex = 0;
145
+ let lastResult = createResult(false);
146
+
147
+ try {
148
+ lastResult = await withRetry(async () => {
149
+ const result = await runGemini(opts.prompt || '', opts.workdir || process.cwd(), preflight, attempts[attemptIndex]);
150
+ const current = { ...result, stderr: appendWarnings(result.stderr, preflight.warnings), retried: attemptIndex > 0 };
151
+ const canRetry = !current.ok && attemptIndex < attempts.length - 1;
152
+ attemptIndex += 1;
153
+ if (!canRetry) return current;
154
+ const error = new Error('retry');
155
+ error.retryable = true;
156
+ error.result = current;
157
+ throw error;
158
+ }, {
159
+ maxAttempts: attempts.length,
160
+ baseDelayMs: 250,
161
+ maxDelayMs: 750,
162
+ shouldRetry: (error) => error?.retryable === true,
163
+ });
164
+ } catch (error) {
165
+ lastResult = error?.result || createResult(false, { stderr: String(error?.message || error) });
166
+ }
167
+
168
+ if (lastResult.ok) {
169
+ breaker.reset();
170
+ return lastResult;
171
+ }
172
+
173
+ breaker.recordFailure(entry.halfOpen);
174
+ return {
175
+ ...lastResult,
176
+ retried: attempts.length > 1,
177
+ fellBack: opts.fallbackToClaude !== false,
178
+ };
179
+ }
package/hub/hitl.mjs ADDED
@@ -0,0 +1,143 @@
1
+ // hub/hitl.mjs — Human-in-the-Loop 매니저
2
+ // 사용자 입력 요청/응답, 타임아웃 자동 처리
3
+
4
+ /**
5
+ * HITL 매니저 생성
6
+ * @param {object} store — createStore() 반환 객체
7
+ * @param {object} router — createRouter() 반환 객체
8
+ */
9
+ export function createHitlManager(store, router = null) {
10
+ function forwardHumanResponse({ requesterAgent, requestId, action, content, submittedBy, correlationId, traceId, priority }) {
11
+ if (!router?.handlePublish) {
12
+ throw new Error('router.handlePublish is required for HITL forwarding');
13
+ }
14
+ return router.handlePublish({
15
+ from: 'hub:hitl',
16
+ to: requesterAgent,
17
+ topic: 'human.response',
18
+ priority,
19
+ ttl_ms: 300000,
20
+ payload: { request_id: requestId, action, content, submitted_by: submittedBy },
21
+ correlation_id: correlationId,
22
+ trace_id: traceId,
23
+ message_type: 'human_response',
24
+ });
25
+ }
26
+
27
+ return {
28
+ /**
29
+ * 사용자에게 입력 요청 생성
30
+ * 터미널에 알림 출력 후 pending 상태로 저장
31
+ */
32
+ requestHumanInput({
33
+ requester_agent, kind, prompt, requested_schema = {},
34
+ deadline_ms, default_action, channel_preference = 'terminal',
35
+ correlation_id, trace_id,
36
+ }) {
37
+ const result = store.insertHumanRequest({
38
+ requester_agent, kind, prompt, requested_schema,
39
+ deadline_ms, default_action,
40
+ correlation_id, trace_id,
41
+ });
42
+
43
+ // 터미널 알림 (stderr — stdout은 MCP 용)
44
+ const kindLabel = { captcha: 'CAPTCHA', approval: '승인', credential: '자격증명', choice: '선택', text: '텍스트' };
45
+ process.stderr.write(
46
+ `\n[tfx-hub] 사용자 입력 요청 (${kindLabel[kind] || kind})\n` +
47
+ ` 요청자: ${requester_agent}\n` +
48
+ ` 내용: ${prompt}\n` +
49
+ ` ID: ${result.request_id}\n` +
50
+ ` 제한: ${Math.round(deadline_ms / 1000)}초\n\n`,
51
+ );
52
+
53
+ return { ok: true, data: result };
54
+ },
55
+
56
+ /**
57
+ * 사용자 입력 응답 제출
58
+ * 유효성 검증 → 상태 업데이트 → 요청자에게 응답 메시지 전달
59
+ */
60
+ submitHumanInput({ request_id, action, content = null, submitted_by = 'human' }) {
61
+ // 요청 조회
62
+ const hr = store.getHumanRequest(request_id);
63
+ if (!hr) {
64
+ return { ok: false, error: { code: 'NOT_FOUND', message: `요청 없음: ${request_id}` } };
65
+ }
66
+ if (hr.state !== 'pending') {
67
+ return { ok: false, error: { code: 'ALREADY_HANDLED', message: `이미 처리됨: ${hr.state}` } };
68
+ }
69
+
70
+ // 상태 매핑
71
+ const stateMap = { accept: 'accepted', decline: 'declined', cancel: 'cancelled' };
72
+ const newState = stateMap[action];
73
+ if (!newState) {
74
+ return { ok: false, error: { code: 'INVALID_ACTION', message: `잘못된 action: ${action}` } };
75
+ }
76
+
77
+ // DB 업데이트
78
+ store.updateHumanRequest(request_id, newState, content);
79
+
80
+ // 요청자에게 응답 메시지 전달
81
+ let forwardedMessageId = null;
82
+ if (action === 'accept' || action === 'decline') {
83
+ const published = forwardHumanResponse({
84
+ requesterAgent: hr.requester_agent,
85
+ requestId: request_id,
86
+ action,
87
+ content,
88
+ submittedBy: submitted_by,
89
+ correlationId: hr.correlation_id,
90
+ traceId: hr.trace_id,
91
+ priority: 7,
92
+ });
93
+ forwardedMessageId = published.data?.message_id || null;
94
+ }
95
+
96
+ return {
97
+ ok: true,
98
+ data: { request_id, new_state: newState, forwarded_message_id: forwardedMessageId },
99
+ };
100
+ },
101
+
102
+ /**
103
+ * 만료된 요청 자동 처리
104
+ * deadline 초과 시 default_action 적용
105
+ */
106
+ checkTimeouts() {
107
+ const pending = store.getPendingHumanRequests();
108
+ const now = Date.now();
109
+ const expired = pending.filter(hr => hr.deadline_ms <= now);
110
+ if (!expired.length) return 0;
111
+
112
+ const expireRequests = () => {
113
+ for (const hr of expired) {
114
+ store.updateHumanRequest(hr.request_id, 'timed_out', null);
115
+ if (hr.default_action === 'timeout_continue') {
116
+ forwardHumanResponse({
117
+ requesterAgent: hr.requester_agent,
118
+ requestId: hr.request_id,
119
+ action: 'timeout_continue',
120
+ content: null,
121
+ submittedBy: 'system',
122
+ correlationId: hr.correlation_id,
123
+ traceId: hr.trace_id,
124
+ priority: 5,
125
+ });
126
+ }
127
+ }
128
+ return expired.length;
129
+ };
130
+
131
+ const processExpired = store.db?.transaction
132
+ ? store.db.transaction(expireRequests)
133
+ : expireRequests;
134
+
135
+ return processExpired();
136
+ },
137
+
138
+ /** 대기 중인 요청 목록 */
139
+ getPendingRequests() {
140
+ return store.getPendingHumanRequests();
141
+ },
142
+ };
143
+ }
package/hub/intent.mjs ADDED
@@ -0,0 +1,193 @@
1
+ // hub/intent.mjs — Intent Classification Engine
2
+ // 사용자 요청의 "진짜 의도"를 분석 → 카테고리 분류 → 최적 에이전트/모델 자동 선택
3
+
4
+ import { execFileSync } from 'node:child_process';
5
+ import crypto from 'node:crypto';
6
+ import { whichCommand } from './platform.mjs';
7
+
8
+ /** 캐시 엔트리: { category, confidence, ts } */
9
+ const _intentCache = new Map();
10
+ const CACHE_TTL_MS = 5 * 60 * 1000; // 5분
11
+
12
+ /** codex 설치 여부 (프로세스당 1회 확인) */
13
+ let _codexAvailable = null;
14
+
15
+ function _isCodexAvailable() {
16
+ if (_codexAvailable !== null) return _codexAvailable;
17
+ _codexAvailable = Boolean(whichCommand('codex'));
18
+ return _codexAvailable;
19
+ }
20
+
21
+ function _promptHash(prompt) {
22
+ return crypto.createHash('md5').update(prompt).digest('hex');
23
+ }
24
+
25
+ function _getCached(hash) {
26
+ const entry = _intentCache.get(hash);
27
+ if (!entry) return null;
28
+ if (Date.now() - entry.ts > CACHE_TTL_MS) {
29
+ _intentCache.delete(hash);
30
+ return null;
31
+ }
32
+ return entry;
33
+ }
34
+
35
+ function _tryCodexClassify(prompt) {
36
+ try {
37
+ const raw = execFileSync(
38
+ 'codex',
39
+ ['exec', `Classify intent: ${prompt}. Reply JSON: {intent, confidence}`],
40
+ { timeout: 8000, encoding: 'utf8' }
41
+ );
42
+ // JSON 블록 추출 (응답에 다른 텍스트가 섞일 수 있음)
43
+ const match = raw.match(/\{[\s\S]*?\}/);
44
+ if (!match) return null;
45
+ const parsed = JSON.parse(match[0]);
46
+ const intent = typeof parsed.intent === 'string' ? parsed.intent : null;
47
+ const confidence = typeof parsed.confidence === 'number' ? parsed.confidence : null;
48
+ if (!intent || confidence === null) return null;
49
+ // intent가 알려진 카테고리여야 함
50
+ if (!INTENT_CATEGORIES[intent]) return null;
51
+ return { category: intent, confidence };
52
+ } catch {
53
+ return null;
54
+ }
55
+ }
56
+
57
+ /** triflux 특화 의도 카테고리 (10개) */
58
+ export const INTENT_CATEGORIES = {
59
+ implement: { agent: 'executor', mcp: 'implement', effort: 'codex53_high' },
60
+ debug: { agent: 'debugger', mcp: 'implement', effort: 'codex53_high' },
61
+ analyze: { agent: 'analyst', mcp: 'analyze', effort: 'gpt54_xhigh' },
62
+ design: { agent: 'architect', mcp: 'analyze', effort: 'gpt54_xhigh' },
63
+ review: { agent: 'code-reviewer', mcp: 'review', effort: 'codex53_high' },
64
+ document: { agent: 'writer', mcp: 'docs', effort: 'pro' },
65
+ research: { agent: 'scientist', mcp: 'analyze', effort: 'codex53_high' },
66
+ 'quick-fix':{ agent: 'build-fixer', mcp: 'implement', effort: 'codex53_low' },
67
+ explain: { agent: 'writer', mcp: 'docs', effort: 'flash' },
68
+ test: { agent: 'test-engineer', mcp: null, effort: null },
69
+ };
70
+
71
+ /** @internal 키워드 → 카테고리 매핑 패턴 */
72
+ const KEYWORD_PATTERNS = [
73
+ { category: 'implement', keywords: ['구현', '만들', '추가', '생성', '작성', '빌드', 'implement', 'create', 'add', 'build', 'make', 'develop'], weight: 1.0 },
74
+ { category: 'debug', keywords: ['버그', '에러', '오류', '고쳐', '수정', '디버그', 'fix', 'bug', 'error', 'debug', 'troubleshoot', 'crash', 'broken'], weight: 1.0 },
75
+ { category: 'analyze', keywords: ['분석', '조사', '파악', 'analyze', 'investigate', 'examine', 'inspect'], weight: 0.9 },
76
+ { category: 'design', keywords: ['설계', '아키텍처', '디자인', '구조', 'design', 'architect', 'structure'], weight: 0.9 },
77
+ { category: 'review', keywords: ['리뷰', '검토', '코드리뷰', 'review', 'code review', 'audit'], weight: 1.0 },
78
+ { category: 'document', keywords: ['문서', '도큐먼트', '문서화', 'document', 'docs', 'documentation', 'readme'], weight: 0.9 },
79
+ { category: 'research', keywords: ['리서치', '연구', '탐색', 'research', 'explore', 'study'], weight: 0.8 },
80
+ { category: 'quick-fix', keywords: ['빠르게', '간단히', '급한', 'quick fix', 'hotfix', 'quick'], weight: 0.85 },
81
+ { category: 'explain', keywords: ['설명', '뭐야', '알려', '이해', 'explain', 'what is', 'how does', 'tell me', 'describe'], weight: 1.0 },
82
+ { category: 'test', keywords: ['테스트', '테스팅', '시험', '검증', 'test', 'testing', 'spec', 'unit test'], weight: 1.0 },
83
+ ];
84
+
85
+ /**
86
+ * 키워드 기반 빠른 분류 (0-cost, Codex 호출 없이)
87
+ * 고신뢰(>0.8) 시 Codex triage 건너뜀
88
+ * @param {string} prompt - 사용자 프롬프트
89
+ * @returns {{ category: string, confidence: number }}
90
+ */
91
+ export function quickClassify(prompt) {
92
+ if (!prompt || typeof prompt !== 'string' || !prompt.trim()) {
93
+ return { category: 'implement', confidence: 0.1 };
94
+ }
95
+
96
+ const lower = prompt.toLowerCase().trim();
97
+ let bestCategory = null;
98
+ let bestScore = 0;
99
+ let bestMatchCount = 0;
100
+
101
+ for (const { category, keywords, weight } of KEYWORD_PATTERNS) {
102
+ let matchCount = 0;
103
+ for (const kw of keywords) {
104
+ if (lower.includes(kw)) matchCount++;
105
+ }
106
+ if (matchCount > 0) {
107
+ const score = (matchCount / keywords.length) * weight;
108
+ if (score > bestScore) {
109
+ bestScore = score;
110
+ bestCategory = category;
111
+ bestMatchCount = matchCount;
112
+ }
113
+ }
114
+ }
115
+
116
+ if (!bestCategory) {
117
+ return { category: 'implement', confidence: 0.3 };
118
+ }
119
+
120
+ // 매칭 품질 기반 신뢰도 (0.5~0.95 범위) — matchCount 기준으로 정규화 (3개 매칭이면 최대)
121
+ const confidence = Math.min(0.95, 0.5 + (Math.min(bestMatchCount, 3) / 3) * 0.45);
122
+ return { category: bestCategory, confidence };
123
+ }
124
+
125
+ /**
126
+ * 전체 의도 분류 — routing 정보 포함
127
+ * Codex triage 경로: codex 설치 시 실행, confidence > 0.8이면 즉시 반환
128
+ * quickClassify가 고신뢰(>0.8)이면 Codex 건너뜀
129
+ * 결과는 md5 해시 기반 Map에 5분 TTL로 캐싱
130
+ * @param {string} prompt
131
+ * @returns {{ category: string, confidence: number, reasoning: string, routing: { agent: string, mcp: string|null, effort: string|null } }}
132
+ */
133
+ export function classifyIntent(prompt) {
134
+ const hash = _promptHash(String(prompt ?? ''));
135
+
136
+ // 캐시 확인
137
+ const cached = _getCached(hash);
138
+ if (cached) {
139
+ const routing = INTENT_CATEGORIES[cached.category] || INTENT_CATEGORIES.implement;
140
+ return {
141
+ category: cached.category,
142
+ confidence: cached.confidence,
143
+ reasoning: `cache-hit: ${cached.category} (${cached.confidence.toFixed(2)})`,
144
+ routing: { agent: routing.agent, mcp: routing.mcp, effort: routing.effort },
145
+ };
146
+ }
147
+
148
+ // quickClassify 먼저
149
+ const quick = quickClassify(prompt);
150
+
151
+ let category = quick.category;
152
+ let confidence = quick.confidence;
153
+ let reasoning;
154
+
155
+ // quickClassify가 고신뢰(>0.8)이면 Codex 건너뜀
156
+ if (quick.confidence > 0.8) {
157
+ reasoning = `keyword-match: ${category} (${confidence.toFixed(2)})`;
158
+ } else if (_isCodexAvailable()) {
159
+ // Codex triage
160
+ const codexResult = _tryCodexClassify(String(prompt ?? ''));
161
+ if (codexResult && codexResult.confidence > 0.8) {
162
+ category = codexResult.category;
163
+ confidence = codexResult.confidence;
164
+ reasoning = `codex-triage: ${category} (${confidence.toFixed(2)})`;
165
+ } else {
166
+ reasoning = `keyword-match(codex-fallback): ${category} (${confidence.toFixed(2)})`;
167
+ }
168
+ } else {
169
+ reasoning = `keyword-match: ${category} (${confidence.toFixed(2)})`;
170
+ }
171
+
172
+ // 캐시 저장
173
+ _intentCache.set(hash, { category, confidence, ts: Date.now() });
174
+
175
+ const routing = INTENT_CATEGORIES[category] || INTENT_CATEGORIES.implement;
176
+ return {
177
+ category,
178
+ confidence,
179
+ reasoning,
180
+ routing: { agent: routing.agent, mcp: routing.mcp, effort: routing.effort },
181
+ };
182
+ }
183
+
184
+ /**
185
+ * 분류 히스토리 기반 학습 (reflexion 연동 가능)
186
+ * @param {string} prompt
187
+ * @param {string} actualCategory - 실제 카테고리
188
+ */
189
+ export function refineClassification(prompt, actualCategory) {
190
+ // reflexion 연동 시 store에 오분류 기록 저장 예정
191
+ void prompt;
192
+ void actualCategory;
193
+ }