triflux 3.2.0-dev.3 → 3.2.0-dev.5
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/README.ko.md +19 -13
- package/README.md +19 -13
- package/bin/triflux.mjs +6 -5
- package/hooks/hooks.json +2 -2
- package/hooks/keyword-rules.json +338 -0
- package/hub/team/cli.mjs +406 -359
- package/hub/team/dashboard.mjs +164 -55
- package/hub/team/native.mjs +38 -0
- package/hud/hud-qos-status.mjs +56 -1
- package/package.json +3 -2
- package/scripts/__tests__/keyword-detector.test.mjs +234 -0
- package/scripts/keyword-detector.mjs +257 -0
- package/scripts/keyword-rules-expander.mjs +521 -0
- package/scripts/lib/keyword-rules.mjs +165 -0
- package/scripts/run.cjs +62 -0
- package/scripts/setup.mjs +5 -4
- package/scripts/test-tfx-route-no-claude-native.mjs +49 -0
- package/scripts/tfx-route.sh +482 -418
- package/skills/tfx-auto-codex/SKILL.md +79 -0
- package/skills/tfx-team/SKILL.md +90 -63
- package/scripts/team-keyword.mjs +0 -35
package/hub/team/dashboard.mjs
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
// hub/team/dashboard.mjs — 실시간 팀 상태 표시
|
|
3
|
-
//
|
|
4
|
-
//
|
|
2
|
+
// hub/team/dashboard.mjs — 실시간 팀 상태 표시 (v2.2)
|
|
3
|
+
// tmux 의존 제거 — Hub task-list + native-supervisor 기반
|
|
4
|
+
//
|
|
5
|
+
// 실행:
|
|
6
|
+
// node hub/team/dashboard.mjs --session <세션이름> [--interval 2]
|
|
7
|
+
// node hub/team/dashboard.mjs --team <팀이름> [--interval 2]
|
|
5
8
|
import { get } from "node:http";
|
|
6
|
-
import { capturePaneOutput } from "./session.mjs";
|
|
7
9
|
|
|
8
10
|
// ── 색상 ──
|
|
9
11
|
const AMBER = "\x1b[38;5;214m";
|
|
@@ -15,32 +17,44 @@ const BOLD = "\x1b[1m";
|
|
|
15
17
|
const RESET = "\x1b[0m";
|
|
16
18
|
|
|
17
19
|
/**
|
|
18
|
-
*
|
|
19
|
-
* @param {string}
|
|
20
|
+
* HTTP GET JSON
|
|
21
|
+
* @param {string} url
|
|
20
22
|
* @returns {Promise<object|null>}
|
|
21
23
|
*/
|
|
22
|
-
function
|
|
24
|
+
function fetchJson(url) {
|
|
23
25
|
return new Promise((resolve) => {
|
|
24
|
-
const url = `${hubUrl}/status`;
|
|
25
26
|
const req = get(url, { timeout: 2000 }, (res) => {
|
|
26
27
|
let data = "";
|
|
27
28
|
res.on("data", (chunk) => (data += chunk));
|
|
28
29
|
res.on("end", () => {
|
|
29
|
-
try {
|
|
30
|
-
resolve(JSON.parse(data));
|
|
31
|
-
} catch {
|
|
32
|
-
resolve(null);
|
|
33
|
-
}
|
|
30
|
+
try { resolve(JSON.parse(data)); } catch { resolve(null); }
|
|
34
31
|
});
|
|
35
32
|
});
|
|
36
33
|
req.on("error", () => resolve(null));
|
|
37
|
-
req.on("timeout", () => {
|
|
38
|
-
req.destroy();
|
|
39
|
-
resolve(null);
|
|
40
|
-
});
|
|
34
|
+
req.on("timeout", () => { req.destroy(); resolve(null); });
|
|
41
35
|
});
|
|
42
36
|
}
|
|
43
37
|
|
|
38
|
+
/**
|
|
39
|
+
* HTTP POST JSON (Hub bridge 용)
|
|
40
|
+
* @param {string} url
|
|
41
|
+
* @param {object} body
|
|
42
|
+
* @returns {Promise<object|null>}
|
|
43
|
+
*/
|
|
44
|
+
async function fetchPost(url, body = {}) {
|
|
45
|
+
try {
|
|
46
|
+
const res = await fetch(url, {
|
|
47
|
+
method: "POST",
|
|
48
|
+
headers: { "Content-Type": "application/json" },
|
|
49
|
+
body: JSON.stringify(body),
|
|
50
|
+
signal: AbortSignal.timeout(2000),
|
|
51
|
+
});
|
|
52
|
+
return await res.json();
|
|
53
|
+
} catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
44
58
|
/**
|
|
45
59
|
* 진행률 바 생성
|
|
46
60
|
* @param {number} pct — 0~100
|
|
@@ -65,56 +79,141 @@ function formatUptime(ms) {
|
|
|
65
79
|
}
|
|
66
80
|
|
|
67
81
|
/**
|
|
68
|
-
*
|
|
69
|
-
* @param {string}
|
|
82
|
+
* task 상태 아이콘
|
|
83
|
+
* @param {string} status
|
|
84
|
+
* @returns {string}
|
|
85
|
+
*/
|
|
86
|
+
function statusIcon(status) {
|
|
87
|
+
switch (status) {
|
|
88
|
+
case "completed": return `${GREEN}✓${RESET}`;
|
|
89
|
+
case "in_progress": return `${AMBER}●${RESET}`;
|
|
90
|
+
case "failed": return `${RED}✗${RESET}`;
|
|
91
|
+
default: return `${GRAY}○${RESET}`;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* 멤버 목록 구성: Hub tasks + supervisor + teamState 통합
|
|
97
|
+
* @param {Array} hubTasks — Hub bridge task-list 결과
|
|
98
|
+
* @param {Array} supervisorMembers — native-supervisor 멤버 상태
|
|
99
|
+
* @param {object} teamState — team-state.json 내용
|
|
100
|
+
* @returns {Array<{name: string, cli: string, status: string, subject: string, preview: string}>}
|
|
101
|
+
*/
|
|
102
|
+
function buildMemberList(hubTasks, supervisorMembers, teamState) {
|
|
103
|
+
const members = [];
|
|
104
|
+
const supervisorByName = new Map(supervisorMembers.map((m) => [m.name, m]));
|
|
105
|
+
|
|
106
|
+
// Hub tasks가 있으면 주 데이터 소스
|
|
107
|
+
if (hubTasks.length > 0) {
|
|
108
|
+
for (const task of hubTasks) {
|
|
109
|
+
const owner = task.owner || task.subject || "";
|
|
110
|
+
const sup = supervisorByName.get(owner);
|
|
111
|
+
members.push({
|
|
112
|
+
name: owner,
|
|
113
|
+
cli: task.metadata?.cli || sup?.cli || "",
|
|
114
|
+
status: task.status || "pending",
|
|
115
|
+
subject: task.subject || "",
|
|
116
|
+
preview: sup?.lastPreview || task.description?.slice(0, 80) || "",
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
return members;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// Supervisor 데이터 폴백
|
|
123
|
+
if (supervisorMembers.length > 0) {
|
|
124
|
+
for (const m of supervisorMembers) {
|
|
125
|
+
if (m.role === "lead") continue;
|
|
126
|
+
members.push({
|
|
127
|
+
name: m.name,
|
|
128
|
+
cli: m.cli || "",
|
|
129
|
+
status: m.status === "running" ? "in_progress" : m.status === "exited" ? "completed" : m.status,
|
|
130
|
+
subject: "",
|
|
131
|
+
preview: m.lastPreview || "",
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
return members;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// teamState 폴백 (하위 호환)
|
|
138
|
+
const panes = teamState?.panes || {};
|
|
139
|
+
for (const [, paneInfo] of Object.entries(panes).filter(([, v]) => v.role !== "dashboard" && v.role !== "lead")) {
|
|
140
|
+
members.push({
|
|
141
|
+
name: paneInfo.agentId || paneInfo.name || "?",
|
|
142
|
+
cli: paneInfo.cli || "",
|
|
143
|
+
status: "unknown",
|
|
144
|
+
subject: paneInfo.subtask || "",
|
|
145
|
+
preview: "",
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
return members;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* 대시보드 렌더링 (v2.2: Hub/supervisor 기반)
|
|
153
|
+
* @param {string} sessionName — 세션 또는 팀 이름
|
|
70
154
|
* @param {object} opts
|
|
71
155
|
* @param {string} opts.hubUrl — Hub URL (기본 http://127.0.0.1:27888)
|
|
72
|
-
* @param {
|
|
156
|
+
* @param {string} [opts.teamName] — Hub task-list 조회용 팀 이름
|
|
157
|
+
* @param {string} [opts.supervisorUrl] — native-supervisor 제어 URL
|
|
158
|
+
* @param {object} [opts.teamState] — team-state.json 내용 (하위 호환)
|
|
73
159
|
*/
|
|
74
160
|
export async function renderDashboard(sessionName, opts = {}) {
|
|
75
|
-
const {
|
|
161
|
+
const {
|
|
162
|
+
hubUrl = "http://127.0.0.1:27888",
|
|
163
|
+
teamName,
|
|
164
|
+
supervisorUrl,
|
|
165
|
+
teamState = {},
|
|
166
|
+
} = opts;
|
|
76
167
|
const W = 50;
|
|
77
168
|
const border = "─".repeat(W);
|
|
78
169
|
|
|
79
|
-
//
|
|
80
|
-
const
|
|
81
|
-
|
|
170
|
+
// 데이터 수집 (병렬)
|
|
171
|
+
const [hubStatus, taskListRes, supervisorRes] = await Promise.all([
|
|
172
|
+
fetchJson(`${hubUrl}/status`),
|
|
173
|
+
teamName ? fetchPost(`${hubUrl}/bridge/team/task-list`, { team_name: teamName }) : null,
|
|
174
|
+
supervisorUrl ? fetchJson(`${supervisorUrl}/status`) : null,
|
|
175
|
+
]);
|
|
176
|
+
|
|
177
|
+
const hubOnline = !!hubStatus;
|
|
82
178
|
const hubState = hubOnline ? `${GREEN}● online${RESET}` : `${RED}● offline${RESET}`;
|
|
83
|
-
const uptime =
|
|
84
|
-
const queueSize =
|
|
179
|
+
const uptime = hubStatus?.hub?.uptime ? formatUptime(hubStatus.hub.uptime) : "-";
|
|
180
|
+
const queueSize = hubStatus?.hub?.queue_depth ?? 0;
|
|
181
|
+
|
|
182
|
+
// Hub task 데이터
|
|
183
|
+
const hubTasks = taskListRes?.ok ? (taskListRes.data?.tasks || []) : [];
|
|
184
|
+
const completedCount = hubTasks.filter((t) => t.status === "completed").length;
|
|
185
|
+
const totalCount = hubTasks.length;
|
|
186
|
+
|
|
187
|
+
// Supervisor 멤버 데이터
|
|
188
|
+
const supervisorMembers = supervisorRes?.ok ? (supervisorRes.data?.members || []) : [];
|
|
85
189
|
|
|
86
190
|
// 헤더
|
|
87
|
-
|
|
191
|
+
const progress = totalCount > 0 ? ` ${completedCount}/${totalCount}` : "";
|
|
192
|
+
console.log(`${AMBER}┌─ ${sessionName}${progress} ${GRAY}${"─".repeat(Math.max(0, W - sessionName.length - progress.length - 3))}${AMBER}┐${RESET}`);
|
|
88
193
|
console.log(`${AMBER}│${RESET} Hub: ${hubState} Uptime: ${DIM}${uptime}${RESET} Queue: ${DIM}${queueSize}${RESET}`);
|
|
89
194
|
console.log(`${AMBER}│${RESET}`);
|
|
90
195
|
|
|
91
|
-
//
|
|
92
|
-
const
|
|
93
|
-
const paneEntries = Object.entries(panes).filter(([, v]) => v.role !== "dashboard");
|
|
196
|
+
// 멤버/워커 렌더링
|
|
197
|
+
const members = buildMemberList(hubTasks, supervisorMembers, teamState);
|
|
94
198
|
|
|
95
|
-
if (
|
|
199
|
+
if (members.length === 0) {
|
|
96
200
|
console.log(`${AMBER}│${RESET} ${DIM}에이전트 정보 없음${RESET}`);
|
|
97
201
|
} else {
|
|
98
|
-
for (const
|
|
99
|
-
const
|
|
100
|
-
const label = `[${
|
|
101
|
-
const cliTag = cli.charAt(0).toUpperCase() + cli.slice(1);
|
|
102
|
-
|
|
103
|
-
//
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
// pane 미리보기 (마지막 2줄)
|
|
114
|
-
const preview = capturePaneOutput(paneTarget, 2)
|
|
115
|
-
.split("\n")
|
|
116
|
-
.filter(Boolean)
|
|
117
|
-
.slice(-1)[0] || "";
|
|
202
|
+
for (const m of members) {
|
|
203
|
+
const icon = statusIcon(m.status);
|
|
204
|
+
const label = `[${m.name}]`;
|
|
205
|
+
const cliTag = m.cli ? m.cli.charAt(0).toUpperCase() + m.cli.slice(1) : "";
|
|
206
|
+
|
|
207
|
+
// 진행률 추정
|
|
208
|
+
const pct = m.status === "completed" ? 100
|
|
209
|
+
: m.status === "in_progress" ? 50
|
|
210
|
+
: m.status === "failed" ? 100
|
|
211
|
+
: 0;
|
|
212
|
+
|
|
213
|
+
console.log(`${AMBER}│${RESET} ${BOLD}${label}${RESET} ${cliTag} ${icon} ${m.status || "pending"} ${progressBar(pct)}`);
|
|
214
|
+
|
|
215
|
+
// 미리보기: supervisor lastPreview > task subject
|
|
216
|
+
const preview = m.preview || m.subject || "";
|
|
118
217
|
if (preview) {
|
|
119
218
|
const truncated = preview.length > W - 8 ? preview.slice(0, W - 11) + "..." : preview;
|
|
120
219
|
console.log(`${AMBER}│${RESET} ${DIM}> ${truncated}${RESET}`);
|
|
@@ -140,14 +239,17 @@ async function loadTeamState() {
|
|
|
140
239
|
}
|
|
141
240
|
}
|
|
142
241
|
|
|
143
|
-
// ── CLI 실행
|
|
242
|
+
// ── CLI 실행 ──
|
|
144
243
|
if (process.argv[1]?.includes("dashboard.mjs")) {
|
|
145
244
|
const sessionIdx = process.argv.indexOf("--session");
|
|
245
|
+
const teamIdx = process.argv.indexOf("--team");
|
|
146
246
|
const sessionName = sessionIdx !== -1 ? process.argv[sessionIdx + 1] : null;
|
|
247
|
+
const teamName = teamIdx !== -1 ? process.argv[teamIdx + 1] : null;
|
|
147
248
|
const intervalSec = parseInt(process.argv[process.argv.indexOf("--interval") + 1] || "2", 10);
|
|
148
249
|
|
|
149
|
-
|
|
150
|
-
|
|
250
|
+
const displayName = sessionName || teamName;
|
|
251
|
+
if (!displayName) {
|
|
252
|
+
console.error("사용법: node dashboard.mjs --session <세션이름> [--team <팀이름>] [--interval 2]");
|
|
151
253
|
process.exit(1);
|
|
152
254
|
}
|
|
153
255
|
|
|
@@ -157,9 +259,16 @@ if (process.argv[1]?.includes("dashboard.mjs")) {
|
|
|
157
259
|
// 갱신 루프
|
|
158
260
|
while (true) {
|
|
159
261
|
const teamState = await loadTeamState();
|
|
262
|
+
const effectiveTeamName = teamName || null;
|
|
263
|
+
const supervisorUrl = teamState?.native?.controlUrl || null;
|
|
264
|
+
|
|
160
265
|
// 화면 클리어 (ANSI)
|
|
161
266
|
process.stdout.write("\x1b[2J\x1b[H");
|
|
162
|
-
await renderDashboard(
|
|
267
|
+
await renderDashboard(displayName, {
|
|
268
|
+
teamName: effectiveTeamName,
|
|
269
|
+
supervisorUrl,
|
|
270
|
+
teamState,
|
|
271
|
+
});
|
|
163
272
|
console.log(`${DIM} ${intervalSec}초 간격 갱신 | Ctrl+C로 종료${RESET}`);
|
|
164
273
|
await new Promise((r) => setTimeout(r, intervalSec * 1000));
|
|
165
274
|
}
|
package/hub/team/native.mjs
CHANGED
|
@@ -83,6 +83,44 @@ export function buildTeamConfig(teamName, assignments) {
|
|
|
83
83
|
};
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
+
/**
|
|
87
|
+
* v2.2 슬림 래퍼 프롬프트 생성
|
|
88
|
+
* Agent spawn으로 네비게이션에 등록하되, 실제 작업은 tfx-route.sh가 수행.
|
|
89
|
+
* 프롬프트 ~100 토큰 목표 (v2의 ~500 대비 80% 감소).
|
|
90
|
+
*
|
|
91
|
+
* @param {'codex'|'gemini'} cli — CLI 타입
|
|
92
|
+
* @param {object} opts
|
|
93
|
+
* @param {string} opts.subtask — 서브태스크 설명
|
|
94
|
+
* @param {string} [opts.role] — 역할 (executor, designer, reviewer 등)
|
|
95
|
+
* @param {string} [opts.teamName] — 팀 이름
|
|
96
|
+
* @param {string} [opts.taskId] — Hub task ID
|
|
97
|
+
* @param {string} [opts.agentName] — 워커 표시 이름
|
|
98
|
+
* @param {string} [opts.leadName] — 리드 수신자 이름
|
|
99
|
+
* @param {string} [opts.mcp_profile] — MCP 프로필
|
|
100
|
+
* @returns {string} 슬림 래퍼 프롬프트
|
|
101
|
+
*/
|
|
102
|
+
export function buildSlimWrapperPrompt(cli, opts = {}) {
|
|
103
|
+
const {
|
|
104
|
+
subtask,
|
|
105
|
+
role = "executor",
|
|
106
|
+
teamName = "tfx-team",
|
|
107
|
+
taskId = "",
|
|
108
|
+
agentName = "",
|
|
109
|
+
leadName = "team-lead",
|
|
110
|
+
mcp_profile = "auto",
|
|
111
|
+
} = opts;
|
|
112
|
+
|
|
113
|
+
// 셸 이스케이프
|
|
114
|
+
const escaped = subtask.replace(/'/g, "'\\''");
|
|
115
|
+
|
|
116
|
+
return `Bash 1회 실행 후 종료.
|
|
117
|
+
|
|
118
|
+
TFX_TEAM_NAME=${teamName} TFX_TEAM_TASK_ID=${taskId} TFX_TEAM_AGENT_NAME=${agentName} TFX_TEAM_LEAD_NAME=${leadName} bash ${ROUTE_SCRIPT} ${role} '${escaped}' ${mcp_profile}
|
|
119
|
+
|
|
120
|
+
완료 → TaskUpdate(status: completed) + SendMessage(to: ${leadName}).
|
|
121
|
+
실패 → TaskUpdate(status: failed) + SendMessage(to: ${leadName}).`;
|
|
122
|
+
}
|
|
123
|
+
|
|
86
124
|
/**
|
|
87
125
|
* 팀 이름 생성 (타임스탬프 기반)
|
|
88
126
|
* @returns {string}
|
package/hud/hud-qos-status.mjs
CHANGED
|
@@ -105,6 +105,9 @@ const QOS_PATH = join(homedir(), ".omc", "state", "cli_qos_profile.json");
|
|
|
105
105
|
const ACCOUNTS_CONFIG_PATH = join(homedir(), ".omc", "router", "accounts.json");
|
|
106
106
|
const ACCOUNTS_STATE_PATH = join(homedir(), ".omc", "state", "cli_accounts_state.json");
|
|
107
107
|
|
|
108
|
+
// tfx-team 상태 (v2.2 HUD 통합)
|
|
109
|
+
const TEAM_STATE_PATH = join(homedir(), ".claude", "cache", "tfx-hub", "team-state.json");
|
|
110
|
+
|
|
108
111
|
// Claude OAuth Usage API (api.anthropic.com/api/oauth/usage)
|
|
109
112
|
const CLAUDE_CREDENTIALS_PATH = join(homedir(), ".claude", ".credentials.json");
|
|
110
113
|
const CLAUDE_USAGE_CACHE_PATH = join(homedir(), ".claude", "cache", "claude-usage-cache.json");
|
|
@@ -426,6 +429,54 @@ function getProviderAccountId(provider, accountsConfig, accountsState) {
|
|
|
426
429
|
return providerConfig[0]?.id || `${provider}-main`;
|
|
427
430
|
}
|
|
428
431
|
|
|
432
|
+
/**
|
|
433
|
+
* tfx-team 상태 행 생성 (v2.2 HUD 통합)
|
|
434
|
+
* 활성 팀이 있을 때만 행 반환, 없으면 null
|
|
435
|
+
* @returns {{ prefix: string, left: string, right: string } | null}
|
|
436
|
+
*/
|
|
437
|
+
function getTeamRow() {
|
|
438
|
+
const teamState = readJson(TEAM_STATE_PATH, null);
|
|
439
|
+
if (!teamState || !teamState.sessionName) return null;
|
|
440
|
+
|
|
441
|
+
// 팀 생존 확인: startedAt 기준 24시간 초과면 stale로 간주
|
|
442
|
+
if (teamState.startedAt && (Date.now() - teamState.startedAt) > 24 * 60 * 60 * 1000) return null;
|
|
443
|
+
|
|
444
|
+
const workers = (teamState.members || []).filter((m) => m.role === "worker");
|
|
445
|
+
if (!workers.length) return null;
|
|
446
|
+
|
|
447
|
+
const tasks = teamState.tasks || [];
|
|
448
|
+
const completed = tasks.filter((t) => t.status === "completed").length;
|
|
449
|
+
const failed = tasks.filter((t) => t.status === "failed").length;
|
|
450
|
+
const total = tasks.length || workers.length;
|
|
451
|
+
|
|
452
|
+
// 경과 시간
|
|
453
|
+
const elapsed = teamState.startedAt
|
|
454
|
+
? `${Math.round((Date.now() - teamState.startedAt) / 60000)}m`
|
|
455
|
+
: "";
|
|
456
|
+
|
|
457
|
+
// 멤버 상태 아이콘 요약
|
|
458
|
+
const memberIcons = workers.map((m) => {
|
|
459
|
+
const task = tasks.find((t) => t.owner === m.name);
|
|
460
|
+
const icon = task?.status === "completed" ? green("✓")
|
|
461
|
+
: task?.status === "in_progress" ? yellow("●")
|
|
462
|
+
: task?.status === "failed" ? red("✗")
|
|
463
|
+
: dim("○");
|
|
464
|
+
const tag = m.cli ? m.cli.charAt(0) : "?";
|
|
465
|
+
return `${tag}${icon}`;
|
|
466
|
+
}).join(" ");
|
|
467
|
+
|
|
468
|
+
// done / failed 상태 텍스트
|
|
469
|
+
const doneText = failed > 0
|
|
470
|
+
? `${completed}/${total} ${red(`${failed}✗`)}`
|
|
471
|
+
: `${completed}/${total} done`;
|
|
472
|
+
|
|
473
|
+
return {
|
|
474
|
+
prefix: bold(claudeOrange("⬡")),
|
|
475
|
+
left: `team ${doneText} ${dim(elapsed)}`,
|
|
476
|
+
right: memberIcons,
|
|
477
|
+
};
|
|
478
|
+
}
|
|
479
|
+
|
|
429
480
|
function renderAlignedRows(rows) {
|
|
430
481
|
const rightRows = rows.filter((row) => stripAnsi(String(row.right || "")).trim().length > 0);
|
|
431
482
|
const rawLeftWidth = rightRows.reduce((max, row) => Math.max(max, stripAnsi(row.left).length), 0);
|
|
@@ -791,7 +842,7 @@ async function fetchClaudeUsage(forceRefresh = false) {
|
|
|
791
842
|
: result.status === 401 || result.status === 403 ? "auth"
|
|
792
843
|
: result.error === "timeout" || result.error === "network" ? "network"
|
|
793
844
|
: "unknown";
|
|
794
|
-
writeClaudeUsageCache(existingSnapshot.data, { type: errorType, status: result.status });
|
|
845
|
+
writeClaudeUsageCache(existingSnapshot.data, { type: errorType, status: result.status });
|
|
795
846
|
return existingSnapshot.data || null;
|
|
796
847
|
}
|
|
797
848
|
const usage = parseClaudeUsageResponse(result.data);
|
|
@@ -1707,6 +1758,10 @@ async function main() {
|
|
|
1707
1758
|
geminiQuotaData, geminiEmail, geminiSv, null),
|
|
1708
1759
|
];
|
|
1709
1760
|
|
|
1761
|
+
// tfx-team 활성 시 팀 상태 행 추가 (v2.2)
|
|
1762
|
+
const teamRow = getTeamRow();
|
|
1763
|
+
if (teamRow) rows.push(teamRow);
|
|
1764
|
+
|
|
1710
1765
|
// 비활성 프로바이더 dim 처리: 데이터 없으면 전체 줄 dim
|
|
1711
1766
|
const codexActive = codexBuckets != null;
|
|
1712
1767
|
const geminiActive = (geminiSession?.total || 0) > 0 || geminiBucket != null;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "triflux",
|
|
3
|
-
"version": "3.2.0-dev.
|
|
3
|
+
"version": "3.2.0-dev.5",
|
|
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": {
|
|
@@ -26,7 +26,8 @@
|
|
|
26
26
|
],
|
|
27
27
|
"scripts": {
|
|
28
28
|
"setup": "node scripts/setup.mjs",
|
|
29
|
-
"postinstall": "node scripts/setup.mjs"
|
|
29
|
+
"postinstall": "node scripts/setup.mjs",
|
|
30
|
+
"test:route-smoke": "node --test scripts/test-tfx-route-no-claude-native.mjs"
|
|
30
31
|
},
|
|
31
32
|
"engines": {
|
|
32
33
|
"node": ">=18.0.0"
|
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { dirname, join, resolve } from "node:path";
|
|
6
|
+
import test from "node:test";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
import { compileRules, loadRules, matchRules, resolveConflicts } from "../lib/keyword-rules.mjs";
|
|
9
|
+
|
|
10
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
11
|
+
const projectRoot = resolve(__dirname, "..", "..");
|
|
12
|
+
const rulesPath = join(projectRoot, "hooks", "keyword-rules.json");
|
|
13
|
+
const detectorScriptPath = join(projectRoot, "scripts", "keyword-detector.mjs");
|
|
14
|
+
|
|
15
|
+
// keyword-detector는 import 시 main()이 실행되므로, 테스트 로딩 단계에서만 안전하게 비활성화한다.
|
|
16
|
+
const previousDisable = process.env.TRIFLUX_DISABLE_MAGICWORDS;
|
|
17
|
+
const previousLog = console.log;
|
|
18
|
+
process.env.TRIFLUX_DISABLE_MAGICWORDS = "1";
|
|
19
|
+
console.log = () => {};
|
|
20
|
+
const detectorModule = await import("../keyword-detector.mjs");
|
|
21
|
+
console.log = previousLog;
|
|
22
|
+
if (previousDisable === undefined) {
|
|
23
|
+
delete process.env.TRIFLUX_DISABLE_MAGICWORDS;
|
|
24
|
+
} else {
|
|
25
|
+
process.env.TRIFLUX_DISABLE_MAGICWORDS = previousDisable;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const { extractPrompt, sanitizeForKeywordDetection } = detectorModule;
|
|
29
|
+
|
|
30
|
+
function loadCompiledRules() {
|
|
31
|
+
const rules = loadRules(rulesPath);
|
|
32
|
+
assert.equal(rules.length, 19);
|
|
33
|
+
return compileRules(rules);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function runDetector(prompt) {
|
|
37
|
+
const payload = { prompt, cwd: projectRoot };
|
|
38
|
+
const result = spawnSync(process.execPath, [detectorScriptPath], {
|
|
39
|
+
input: JSON.stringify(payload),
|
|
40
|
+
encoding: "utf8"
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
assert.equal(result.status, 0, result.stderr);
|
|
44
|
+
assert.ok(result.stdout.trim(), "keyword-detector 출력이 비어 있습니다.");
|
|
45
|
+
return JSON.parse(result.stdout.trim());
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
test("extractPrompt: prompt/message.content/parts[].text 우선순위", () => {
|
|
49
|
+
assert.equal(
|
|
50
|
+
extractPrompt({
|
|
51
|
+
prompt: "from prompt",
|
|
52
|
+
message: { content: "from message" },
|
|
53
|
+
parts: [{ text: "from parts" }]
|
|
54
|
+
}),
|
|
55
|
+
"from prompt"
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
assert.equal(
|
|
59
|
+
extractPrompt({
|
|
60
|
+
prompt: " ",
|
|
61
|
+
message: { content: "from message" },
|
|
62
|
+
parts: [{ text: "from parts" }]
|
|
63
|
+
}),
|
|
64
|
+
"from message"
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
assert.equal(
|
|
68
|
+
extractPrompt({
|
|
69
|
+
message: { content: [{ text: "from message-part" }] },
|
|
70
|
+
parts: [{ text: "from parts" }]
|
|
71
|
+
}),
|
|
72
|
+
"from message-part"
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
assert.equal(extractPrompt({ parts: [{ text: "from parts" }] }), "from parts");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("sanitizeForKeywordDetection: 코드블록/URL/파일경로/XML 태그 제거", () => {
|
|
79
|
+
const input = [
|
|
80
|
+
"정상 문장",
|
|
81
|
+
"```sh",
|
|
82
|
+
"tfx team",
|
|
83
|
+
"```",
|
|
84
|
+
"https://example.com/path?q=1",
|
|
85
|
+
"C:\\Users\\SSAFY\\Desktop\\Projects\\tools\\triflux",
|
|
86
|
+
"./hooks/keyword-rules.json",
|
|
87
|
+
"<tag>jira 이슈 생성</tag>"
|
|
88
|
+
].join("\n");
|
|
89
|
+
|
|
90
|
+
const sanitized = sanitizeForKeywordDetection(input);
|
|
91
|
+
|
|
92
|
+
assert.ok(sanitized.includes("정상 문장"));
|
|
93
|
+
assert.ok(!sanitized.includes("tfx team"));
|
|
94
|
+
assert.ok(!sanitized.includes("https://"));
|
|
95
|
+
assert.ok(!sanitized.includes("C:\\Users\\"));
|
|
96
|
+
assert.ok(!sanitized.includes("./hooks/keyword-rules.json"));
|
|
97
|
+
assert.ok(!sanitized.includes("<tag>"));
|
|
98
|
+
assert.ok(!sanitized.includes("jira 이슈 생성"));
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
test("loadRules: 유효한 JSON 로드", () => {
|
|
102
|
+
const rules = loadRules(rulesPath);
|
|
103
|
+
assert.equal(rules.length, 19);
|
|
104
|
+
assert.equal(rules.filter((rule) => rule.skill).length, 9);
|
|
105
|
+
assert.equal(rules.filter((rule) => rule.mcp_route).length, 10);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("loadRules: 잘못된 파일 처리", () => {
|
|
109
|
+
const tempDir = mkdtempSync(join(tmpdir(), "triflux-rules-"));
|
|
110
|
+
const invalidPath = join(tempDir, "invalid.json");
|
|
111
|
+
writeFileSync(invalidPath, "{ invalid json", "utf8");
|
|
112
|
+
|
|
113
|
+
const malformed = loadRules(invalidPath);
|
|
114
|
+
const missing = loadRules(join(tempDir, "missing.json"));
|
|
115
|
+
|
|
116
|
+
assert.deepEqual(malformed, []);
|
|
117
|
+
assert.deepEqual(missing, []);
|
|
118
|
+
|
|
119
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("compileRules: 정규식 컴파일 성공", () => {
|
|
123
|
+
const rules = loadRules(rulesPath);
|
|
124
|
+
const compiled = compileRules(rules);
|
|
125
|
+
assert.equal(compiled.length, 19);
|
|
126
|
+
for (const rule of compiled) {
|
|
127
|
+
assert.ok(Array.isArray(rule.compiledPatterns));
|
|
128
|
+
assert.ok(rule.compiledPatterns.length > 0);
|
|
129
|
+
for (const pattern of rule.compiledPatterns) {
|
|
130
|
+
assert.ok(pattern instanceof RegExp);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
test("compileRules: 정규식 컴파일 실패", () => {
|
|
136
|
+
const compiled = compileRules([
|
|
137
|
+
{
|
|
138
|
+
id: "bad-pattern",
|
|
139
|
+
priority: 1,
|
|
140
|
+
patterns: [{ source: "[", flags: "" }],
|
|
141
|
+
skill: "tfx-team",
|
|
142
|
+
supersedes: [],
|
|
143
|
+
exclusive: false,
|
|
144
|
+
state: null,
|
|
145
|
+
mcp_route: null
|
|
146
|
+
}
|
|
147
|
+
]);
|
|
148
|
+
|
|
149
|
+
assert.deepEqual(compiled, []);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
test("matchRules: tfx 키워드 매칭", () => {
|
|
153
|
+
const compiledRules = loadCompiledRules();
|
|
154
|
+
const cases = [
|
|
155
|
+
{ text: "tfx team 세션 시작", expectedId: "tfx-team" },
|
|
156
|
+
{ text: "tfx auto 돌려줘", expectedId: "tfx-auto" },
|
|
157
|
+
{ text: "tfx codex 로 실행", expectedId: "tfx-codex" },
|
|
158
|
+
{ text: "tfx gemini 로 실행", expectedId: "tfx-gemini" },
|
|
159
|
+
{ text: "canceltfx", expectedId: "tfx-cancel" }
|
|
160
|
+
];
|
|
161
|
+
|
|
162
|
+
for (const { text, expectedId } of cases) {
|
|
163
|
+
const clean = sanitizeForKeywordDetection(text);
|
|
164
|
+
const matches = matchRules(compiledRules, clean);
|
|
165
|
+
assert.ok(matches.some((match) => match.id === expectedId), `${text} => ${expectedId} 미매칭`);
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("matchRules: MCP 라우팅 매칭", () => {
|
|
170
|
+
const compiledRules = loadCompiledRules();
|
|
171
|
+
const cases = [
|
|
172
|
+
{ text: "노션 페이지 조회해줘", expectedId: "notion-route", expectedRoute: "gemini" },
|
|
173
|
+
{ text: "jira 이슈 생성", expectedId: "jira-route", expectedRoute: "codex" },
|
|
174
|
+
{ text: "크롬 열고 로그인", expectedId: "chrome-route", expectedRoute: "gemini" },
|
|
175
|
+
{ text: "이메일 보내줘", expectedId: "mail-route", expectedRoute: "gemini" },
|
|
176
|
+
{ text: "캘린더 일정 생성", expectedId: "calendar-route", expectedRoute: "gemini" },
|
|
177
|
+
{ text: "playwright 테스트 작성", expectedId: "playwright-route", expectedRoute: "gemini" },
|
|
178
|
+
{ text: "canva 디자인 생성", expectedId: "canva-route", expectedRoute: "gemini" }
|
|
179
|
+
];
|
|
180
|
+
|
|
181
|
+
for (const { text, expectedId, expectedRoute } of cases) {
|
|
182
|
+
const matches = matchRules(compiledRules, sanitizeForKeywordDetection(text));
|
|
183
|
+
const matched = matches.find((match) => match.id === expectedId);
|
|
184
|
+
assert.ok(matched, `${text} => ${expectedId} 미매칭`);
|
|
185
|
+
assert.equal(matched.mcp_route, expectedRoute);
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("matchRules: 일반 대화는 매칭 없음", () => {
|
|
190
|
+
const compiledRules = loadCompiledRules();
|
|
191
|
+
const matches = matchRules(compiledRules, sanitizeForKeywordDetection("오늘 점심 메뉴 추천해줘"));
|
|
192
|
+
assert.deepEqual(matches, []);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test("resolveConflicts: priority 정렬 및 supersedes 처리", () => {
|
|
196
|
+
const resolved = resolveConflicts([
|
|
197
|
+
{ id: "rule-c", priority: 3, supersedes: [], exclusive: false },
|
|
198
|
+
{ id: "rule-b", priority: 2, supersedes: ["rule-c"], exclusive: false },
|
|
199
|
+
{ id: "rule-a", priority: 1, supersedes: [], exclusive: false },
|
|
200
|
+
{ id: "rule-a", priority: 1, supersedes: [], exclusive: false }
|
|
201
|
+
]);
|
|
202
|
+
|
|
203
|
+
assert.deepEqual(
|
|
204
|
+
resolved.map((rule) => rule.id),
|
|
205
|
+
["rule-a", "rule-b"]
|
|
206
|
+
);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
test("resolveConflicts: exclusive 처리", () => {
|
|
210
|
+
const resolved = resolveConflicts([
|
|
211
|
+
{ id: "normal", priority: 1, supersedes: [], exclusive: false },
|
|
212
|
+
{ id: "exclusive", priority: 0, supersedes: [], exclusive: true },
|
|
213
|
+
{ id: "later", priority: 2, supersedes: [], exclusive: false }
|
|
214
|
+
]);
|
|
215
|
+
|
|
216
|
+
assert.deepEqual(resolved.map((rule) => rule.id), ["exclusive"]);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
test("코드블록 내 키워드: sanitize 후 매칭 안 됨", () => {
|
|
220
|
+
const compiledRules = loadCompiledRules();
|
|
221
|
+
const input = ["```txt", "tfx team", "jira 이슈 생성", "```"].join("\n");
|
|
222
|
+
const clean = sanitizeForKeywordDetection(input);
|
|
223
|
+
const matches = matchRules(compiledRules, clean);
|
|
224
|
+
assert.deepEqual(matches, []);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
test("OMC 키워드와 triflux 키워드 비간섭 + TRIFLUX 네임스페이스", () => {
|
|
228
|
+
const omcLike = runDetector("my tfx team 세션 보여줘");
|
|
229
|
+
assert.equal(omcLike.suppressOutput, true);
|
|
230
|
+
|
|
231
|
+
const triflux = runDetector("tfx team 세션 시작");
|
|
232
|
+
const additionalContext = triflux?.hookSpecificOutput?.additionalContext || "";
|
|
233
|
+
assert.match(additionalContext, /^\[TRIFLUX MAGIC KEYWORD: tfx-team\]/);
|
|
234
|
+
});
|