triflux 3.3.0-dev.8 → 4.0.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 (91) hide show
  1. package/README.ko.md +108 -199
  2. package/README.md +108 -199
  3. package/bin/triflux.mjs +2427 -1762
  4. package/hooks/keyword-rules.json +361 -354
  5. package/hooks/pipeline-stop.mjs +5 -2
  6. package/hub/assign-callbacks.mjs +136 -136
  7. package/hub/bridge.mjs +734 -684
  8. package/hub/delegator/contracts.mjs +38 -38
  9. package/hub/delegator/index.mjs +14 -14
  10. package/hub/delegator/schema/delegator-tools.schema.json +250 -250
  11. package/hub/delegator/service.mjs +302 -118
  12. package/hub/delegator/tool-definitions.mjs +35 -35
  13. package/hub/hitl.mjs +67 -67
  14. package/hub/paths.mjs +28 -0
  15. package/hub/pipe.mjs +589 -561
  16. package/hub/pipeline/state.mjs +23 -0
  17. package/hub/public/dashboard.html +349 -0
  18. package/hub/public/tray-icon.ico +0 -0
  19. package/hub/public/tray-icon.png +0 -0
  20. package/hub/router.mjs +782 -782
  21. package/hub/schema.sql +40 -40
  22. package/hub/server.mjs +810 -637
  23. package/hub/store.mjs +706 -706
  24. package/hub/team/cli/commands/attach.mjs +37 -0
  25. package/hub/team/cli/commands/control.mjs +43 -0
  26. package/hub/team/cli/commands/debug.mjs +74 -0
  27. package/hub/team/cli/commands/focus.mjs +53 -0
  28. package/hub/team/cli/commands/interrupt.mjs +36 -0
  29. package/hub/team/cli/commands/kill.mjs +37 -0
  30. package/hub/team/cli/commands/list.mjs +24 -0
  31. package/hub/team/cli/commands/send.mjs +37 -0
  32. package/hub/team/cli/commands/start/index.mjs +87 -0
  33. package/hub/team/cli/commands/start/parse-args.mjs +32 -0
  34. package/hub/team/cli/commands/start/start-in-process.mjs +40 -0
  35. package/hub/team/cli/commands/start/start-mux.mjs +73 -0
  36. package/hub/team/cli/commands/start/start-wt.mjs +69 -0
  37. package/hub/team/cli/commands/status.mjs +87 -0
  38. package/hub/team/cli/commands/stop.mjs +31 -0
  39. package/hub/team/cli/commands/task.mjs +30 -0
  40. package/hub/team/cli/commands/tasks.mjs +13 -0
  41. package/hub/team/{cli.mjs → cli/help.mjs} +38 -99
  42. package/hub/team/cli/index.mjs +39 -0
  43. package/hub/team/cli/manifest.mjs +28 -0
  44. package/hub/team/cli/render.mjs +30 -0
  45. package/hub/team/cli/services/attach-fallback.mjs +54 -0
  46. package/hub/team/cli/services/hub-client.mjs +171 -0
  47. package/hub/team/cli/services/member-selector.mjs +30 -0
  48. package/hub/team/cli/services/native-control.mjs +115 -0
  49. package/hub/team/cli/services/runtime-mode.mjs +60 -0
  50. package/hub/team/cli/services/state-store.mjs +34 -0
  51. package/hub/team/cli/services/task-model.mjs +30 -0
  52. package/hub/team/native-supervisor.mjs +69 -63
  53. package/hub/team/native.mjs +367 -367
  54. package/hub/team/nativeProxy.mjs +217 -173
  55. package/hub/team/pane.mjs +149 -149
  56. package/hub/team/psmux.mjs +946 -946
  57. package/hub/team/session.mjs +608 -608
  58. package/hub/team/staleState.mjs +369 -299
  59. package/hub/tools.mjs +107 -107
  60. package/hub/tray.mjs +332 -0
  61. package/hub/workers/claude-worker.mjs +446 -446
  62. package/hub/workers/codex-mcp.mjs +414 -414
  63. package/hub/workers/delegator-mcp.mjs +1045 -1045
  64. package/hub/workers/factory.mjs +21 -21
  65. package/hub/workers/gemini-worker.mjs +349 -349
  66. package/hub/workers/interface.mjs +41 -41
  67. package/package.json +3 -2
  68. package/scripts/__tests__/keyword-detector.test.mjs +234 -234
  69. package/scripts/hub-ensure.mjs +102 -101
  70. package/scripts/keyword-detector.mjs +272 -272
  71. package/scripts/keyword-rules-expander.mjs +521 -521
  72. package/scripts/lib/keyword-rules.mjs +168 -168
  73. package/scripts/lib/mcp-filter.mjs +642 -642
  74. package/scripts/lib/mcp-server-catalog.mjs +118 -118
  75. package/scripts/mcp-check.mjs +126 -126
  76. package/scripts/preflight-cache.mjs +19 -0
  77. package/scripts/run.cjs +62 -62
  78. package/scripts/setup.mjs +68 -31
  79. package/scripts/test-tfx-route-no-claude-native.mjs +57 -57
  80. package/scripts/tfx-route-worker.mjs +161 -161
  81. package/scripts/tfx-route.sh +1360 -1326
  82. package/skills/tfx-auto/SKILL.md +196 -196
  83. package/skills/tfx-auto-codex/SKILL.md +77 -77
  84. package/skills/tfx-multi/SKILL.md +378 -378
  85. package/hub/team/cli-team-common.mjs +0 -348
  86. package/hub/team/cli-team-control.mjs +0 -393
  87. package/hub/team/cli-team-start.mjs +0 -516
  88. package/hub/team/cli-team-status.mjs +0 -283
  89. package/skills/auto-verify/SKILL.md +0 -145
  90. package/skills/manage-skills/SKILL.md +0 -192
  91. package/skills/verify-implementation/SKILL.md +0 -138
@@ -1,41 +1,41 @@
1
- // hub/workers/interface.mjs — Worker 공통 인터페이스 정의
2
-
3
- /**
4
- * 워커 실행 옵션
5
- * @typedef {object} WorkerExecuteOptions
6
- * @property {string} [cwd] - 워커 작업 디렉터리
7
- * @property {string} [sessionKey] - 내부 세션 키
8
- * @property {string} [threadId] - 외부에서 지정한 Codex threadId
9
- * @property {boolean} [resetSession] - 기존 세션을 무시하고 새 세션 시작 여부
10
- * @property {string} [model] - Codex 모델 이름
11
- * @property {string} [profile] - Codex 프로필 이름
12
- * @property {'untrusted'|'on-failure'|'on-request'|'never'} [approvalPolicy] - 승인 정책
13
- * @property {'read-only'|'workspace-write'|'danger-full-access'} [sandbox] - 샌드박스 정책
14
- * @property {Record<string, unknown>} [config] - 추가 Codex 설정
15
- * @property {string} [baseInstructions] - 기본 시스템 지침
16
- * @property {string} [developerInstructions] - 개발자 지침
17
- * @property {string} [compactPrompt] - 컴팩션 프롬프트
18
- * @property {number} [timeoutMs] - MCP 요청 타임아웃(ms)
19
- */
20
-
21
- /**
22
- * 워커 실행 결과
23
- * @typedef {object} WorkerResult
24
- * @property {string} output - 최종 텍스트 출력
25
- * @property {number} exitCode - 종료 코드(0=성공)
26
- * @property {string | null} [threadId] - Codex 세션 threadId
27
- * @property {string | null} [sessionKey] - 내부 세션 키
28
- * @property {unknown} [raw] - 원본 tool call 결과
29
- */
30
-
31
- /**
32
- * 공통 워커 인터페이스
33
- * @typedef {object} IWorker
34
- * @property {(prompt: string, opts?: WorkerExecuteOptions) => Promise<WorkerResult>} execute
35
- * @property {() => Promise<void>} start
36
- * @property {() => Promise<void>} stop
37
- * @property {() => boolean} isReady
38
- * @property {string} type - 'codex' | 'gemini' | 'claude' | 'delegator'
39
- */
40
-
41
- export const WORKER_TYPES = Object.freeze(['codex', 'gemini', 'claude', 'delegator']);
1
+ // hub/workers/interface.mjs — Worker 공통 인터페이스 정의
2
+
3
+ /**
4
+ * 워커 실행 옵션
5
+ * @typedef {object} WorkerExecuteOptions
6
+ * @property {string} [cwd] - 워커 작업 디렉터리
7
+ * @property {string} [sessionKey] - 내부 세션 키
8
+ * @property {string} [threadId] - 외부에서 지정한 Codex threadId
9
+ * @property {boolean} [resetSession] - 기존 세션을 무시하고 새 세션 시작 여부
10
+ * @property {string} [model] - Codex 모델 이름
11
+ * @property {string} [profile] - Codex 프로필 이름
12
+ * @property {'untrusted'|'on-failure'|'on-request'|'never'} [approvalPolicy] - 승인 정책
13
+ * @property {'read-only'|'workspace-write'|'danger-full-access'} [sandbox] - 샌드박스 정책
14
+ * @property {Record<string, unknown>} [config] - 추가 Codex 설정
15
+ * @property {string} [baseInstructions] - 기본 시스템 지침
16
+ * @property {string} [developerInstructions] - 개발자 지침
17
+ * @property {string} [compactPrompt] - 컴팩션 프롬프트
18
+ * @property {number} [timeoutMs] - MCP 요청 타임아웃(ms)
19
+ */
20
+
21
+ /**
22
+ * 워커 실행 결과
23
+ * @typedef {object} WorkerResult
24
+ * @property {string} output - 최종 텍스트 출력
25
+ * @property {number} exitCode - 종료 코드(0=성공)
26
+ * @property {string | null} [threadId] - Codex 세션 threadId
27
+ * @property {string | null} [sessionKey] - 내부 세션 키
28
+ * @property {unknown} [raw] - 원본 tool call 결과
29
+ */
30
+
31
+ /**
32
+ * 공통 워커 인터페이스
33
+ * @typedef {object} IWorker
34
+ * @property {(prompt: string, opts?: WorkerExecuteOptions) => Promise<WorkerResult>} execute
35
+ * @property {() => Promise<void>} start
36
+ * @property {() => Promise<void>} stop
37
+ * @property {() => boolean} isReady
38
+ * @property {string} type - 'codex' | 'gemini' | 'claude' | 'delegator'
39
+ */
40
+
41
+ export const WORKER_TYPES = Object.freeze(['codex', 'gemini', 'claude', 'delegator']);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "triflux",
3
- "version": "3.3.0-dev.8",
3
+ "version": "4.0.1",
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": {
@@ -43,8 +43,9 @@
43
43
  "author": "tellang",
44
44
  "license": "MIT",
45
45
  "dependencies": {
46
+ "@modelcontextprotocol/sdk": "^1.27.1",
46
47
  "better-sqlite3": "^12.6.2",
47
- "@modelcontextprotocol/sdk": "^1.27.1"
48
+ "systray2": "^2.1.4"
48
49
  },
49
50
  "keywords": [
50
51
  "claude-code",
@@ -1,234 +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, 20);
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 multi",
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 multi"));
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, 20);
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, 20);
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-multi",
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 multi 세션 시작", expectedId: "tfx-multi" },
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 multi", "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 multi 세션 보여줘");
229
- assert.equal(omcLike.suppressOutput, true);
230
-
231
- const triflux = runDetector("tfx multi 세션 시작");
232
- const additionalContext = triflux?.hookSpecificOutput?.additionalContext || "";
233
- assert.match(additionalContext, /^\[TRIFLUX MAGIC KEYWORD: tfx-multi\]/);
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, 21);
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 multi",
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 multi"));
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, 21);
104
+ assert.equal(rules.filter((rule) => rule.skill).length, 10);
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, 21);
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-multi",
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 multi 세션 시작", expectedId: "tfx-multi" },
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 multi", "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 multi 세션 보여줘");
229
+ assert.equal(omcLike.suppressOutput, true);
230
+
231
+ const triflux = runDetector("tfx multi 세션 시작");
232
+ const additionalContext = triflux?.hookSpecificOutput?.additionalContext || "";
233
+ assert.match(additionalContext, /^\[TRIFLUX MAGIC KEYWORD: tfx-multi\]/);
234
+ });