triflux 3.2.0-dev.1 → 3.2.0-dev.11

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 (53) hide show
  1. package/README.ko.md +26 -18
  2. package/README.md +26 -18
  3. package/bin/triflux.mjs +1614 -1084
  4. package/hooks/hooks.json +12 -0
  5. package/hooks/keyword-rules.json +354 -0
  6. package/hub/bridge.mjs +371 -193
  7. package/hub/hitl.mjs +45 -31
  8. package/hub/pipe.mjs +457 -0
  9. package/hub/router.mjs +422 -161
  10. package/hub/server.mjs +429 -344
  11. package/hub/store.mjs +388 -314
  12. package/hub/team/cli-team-common.mjs +348 -0
  13. package/hub/team/cli-team-control.mjs +393 -0
  14. package/hub/team/cli-team-start.mjs +516 -0
  15. package/hub/team/cli-team-status.mjs +269 -0
  16. package/hub/team/cli.mjs +99 -368
  17. package/hub/team/dashboard.mjs +165 -64
  18. package/hub/team/native-supervisor.mjs +300 -0
  19. package/hub/team/native.mjs +62 -0
  20. package/hub/team/nativeProxy.mjs +534 -0
  21. package/hub/team/orchestrator.mjs +90 -31
  22. package/hub/team/pane.mjs +149 -101
  23. package/hub/team/psmux.mjs +297 -0
  24. package/hub/team/session.mjs +608 -186
  25. package/hub/team/shared.mjs +13 -0
  26. package/hub/team/staleState.mjs +299 -0
  27. package/hub/tools.mjs +140 -53
  28. package/hub/workers/claude-worker.mjs +446 -0
  29. package/hub/workers/codex-mcp.mjs +414 -0
  30. package/hub/workers/factory.mjs +18 -0
  31. package/hub/workers/gemini-worker.mjs +349 -0
  32. package/hub/workers/interface.mjs +41 -0
  33. package/hud/hud-qos-status.mjs +1789 -1732
  34. package/package.json +6 -2
  35. package/scripts/__tests__/keyword-detector.test.mjs +234 -0
  36. package/scripts/hub-ensure.mjs +83 -0
  37. package/scripts/keyword-detector.mjs +272 -0
  38. package/scripts/keyword-rules-expander.mjs +521 -0
  39. package/scripts/lib/keyword-rules.mjs +168 -0
  40. package/scripts/psmux-steering-prototype.sh +368 -0
  41. package/scripts/run.cjs +62 -0
  42. package/scripts/setup.mjs +189 -7
  43. package/scripts/test-tfx-route-no-claude-native.mjs +49 -0
  44. package/scripts/tfx-route-worker.mjs +161 -0
  45. package/scripts/tfx-route.sh +943 -508
  46. package/skills/tfx-auto/SKILL.md +90 -564
  47. package/skills/tfx-auto-codex/SKILL.md +77 -0
  48. package/skills/tfx-codex/SKILL.md +1 -4
  49. package/skills/tfx-doctor/SKILL.md +1 -0
  50. package/skills/tfx-gemini/SKILL.md +1 -4
  51. package/skills/tfx-multi/SKILL.md +296 -0
  52. package/skills/tfx-setup/SKILL.md +1 -4
  53. package/skills/tfx-team/SKILL.md +0 -172
@@ -0,0 +1,521 @@
1
+ #!/usr/bin/env node
2
+ // session-vault 태그 빈도 기반으로 keyword-rules.json 확장 후보를 제안하는 스크립트
3
+
4
+ import Database from "better-sqlite3";
5
+ import { readFileSync, writeFileSync } from "node:fs";
6
+ import { homedir } from "node:os";
7
+ import { dirname, join, resolve } from "node:path";
8
+ import { fileURLToPath } from "node:url";
9
+
10
+ const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
11
+ const PROJECT_ROOT = dirname(SCRIPT_DIR);
12
+ const RULES_PATH = join(PROJECT_ROOT, "hooks", "keyword-rules.json");
13
+ const DEFAULT_DB_PATH = "~/Desktop/Projects/tools/session-vault/sessions_v2.db";
14
+ const DEFAULT_THRESHOLD = 3;
15
+ const SOURCE_FILTER = "ollama-%";
16
+
17
+ // 서비스명 → 기본 mcp_route 매핑
18
+ const MCP_SERVICE_ROUTE_MAP = {
19
+ notion: "gemini",
20
+ jira: "codex",
21
+ chrome: "gemini",
22
+ playwright: "gemini",
23
+ canva: "gemini",
24
+ calendar: "gemini",
25
+ gmail: "gemini",
26
+ email: "gemini",
27
+ github: "codex",
28
+ figma: "gemini"
29
+ };
30
+
31
+ const MCP_SERVICE_NAMES = new Set([
32
+ ...Object.keys(MCP_SERVICE_ROUTE_MAP),
33
+ "slack",
34
+ "linear",
35
+ "confluence",
36
+ "trello",
37
+ "asana",
38
+ "drive",
39
+ "sheets",
40
+ "docs"
41
+ ]);
42
+
43
+ function printUsage() {
44
+ console.log("사용법:");
45
+ console.log(" node scripts/keyword-rules-expander.mjs --dry-run");
46
+ console.log(" node scripts/keyword-rules-expander.mjs --threshold 5");
47
+ console.log(" node scripts/keyword-rules-expander.mjs --apply");
48
+ console.log(" node scripts/keyword-rules-expander.mjs --db-path ./other.db");
49
+ }
50
+
51
+ function parseArgs(argv) {
52
+ const args = {
53
+ dryRun: false,
54
+ apply: false,
55
+ threshold: DEFAULT_THRESHOLD,
56
+ dbPath: DEFAULT_DB_PATH,
57
+ help: false
58
+ };
59
+
60
+ for (let i = 0; i < argv.length; i += 1) {
61
+ const token = argv[i];
62
+
63
+ if (token === "--dry-run") {
64
+ args.dryRun = true;
65
+ continue;
66
+ }
67
+
68
+ if (token === "--apply") {
69
+ args.apply = true;
70
+ continue;
71
+ }
72
+
73
+ if (token === "--help" || token === "-h") {
74
+ args.help = true;
75
+ continue;
76
+ }
77
+
78
+ if (token === "--threshold") {
79
+ const next = argv[i + 1];
80
+ if (!next) throw new Error("--threshold 값이 필요합니다.");
81
+ const parsed = Number.parseInt(next, 10);
82
+ if (!Number.isInteger(parsed) || parsed < 1) {
83
+ throw new Error("--threshold는 1 이상의 정수여야 합니다.");
84
+ }
85
+ args.threshold = parsed;
86
+ i += 1;
87
+ continue;
88
+ }
89
+
90
+ if (token.startsWith("--threshold=")) {
91
+ const raw = token.slice("--threshold=".length);
92
+ const parsed = Number.parseInt(raw, 10);
93
+ if (!Number.isInteger(parsed) || parsed < 1) {
94
+ throw new Error("--threshold는 1 이상의 정수여야 합니다.");
95
+ }
96
+ args.threshold = parsed;
97
+ continue;
98
+ }
99
+
100
+ if (token === "--db-path") {
101
+ const next = argv[i + 1];
102
+ if (!next) throw new Error("--db-path 값이 필요합니다.");
103
+ args.dbPath = next;
104
+ i += 1;
105
+ continue;
106
+ }
107
+
108
+ if (token.startsWith("--db-path=")) {
109
+ args.dbPath = token.slice("--db-path=".length);
110
+ continue;
111
+ }
112
+
113
+ throw new Error(`알 수 없는 옵션: ${token}`);
114
+ }
115
+
116
+ if (!args.dryRun && !args.apply) args.dryRun = true;
117
+ if (args.apply) args.dryRun = false;
118
+ return args;
119
+ }
120
+
121
+ function expandHomePath(inputPath) {
122
+ if (!inputPath) return inputPath;
123
+ if (inputPath === "~") return homedir();
124
+ if (inputPath.startsWith("~/") || inputPath.startsWith("~\\")) {
125
+ return join(homedir(), inputPath.slice(2));
126
+ }
127
+ return inputPath;
128
+ }
129
+
130
+ function toDisplayPath(pathValue) {
131
+ const homePath = homedir();
132
+ if (pathValue.toLowerCase().startsWith(homePath.toLowerCase())) {
133
+ let rest = pathValue.slice(homePath.length).replace(/\\/g, "/");
134
+ if (rest && !rest.startsWith("/")) rest = `/${rest}`;
135
+ return `~${rest}`;
136
+ }
137
+ return pathValue;
138
+ }
139
+
140
+ function normalizeKeyword(value) {
141
+ if (typeof value !== "string") return "";
142
+ return value.trim().toLowerCase();
143
+ }
144
+
145
+ function slugifyKeyword(value) {
146
+ const base = normalizeKeyword(value)
147
+ .replace(/[^a-z0-9]+/g, "-")
148
+ .replace(/^-+|-+$/g, "")
149
+ .replace(/-{2,}/g, "-");
150
+ return base || "keyword";
151
+ }
152
+
153
+ function escapeRegExp(value) {
154
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
155
+ }
156
+
157
+ function splitSources(raw) {
158
+ if (typeof raw !== "string" || !raw.trim()) return [];
159
+ return raw
160
+ .split(",")
161
+ .map((item) => item.trim())
162
+ .filter(Boolean);
163
+ }
164
+
165
+ function fetchTagFrequencyRows(dbPath) {
166
+ const db = new Database(dbPath, { readonly: true, fileMustExist: true });
167
+
168
+ try {
169
+ const stmt = db.prepare(`
170
+ SELECT
171
+ t.tag AS tag,
172
+ COUNT(*) AS frequency,
173
+ GROUP_CONCAT(DISTINCT tt.source) AS sources
174
+ FROM turn_tags tt
175
+ INNER JOIN tags t ON t.id = tt.tag_id
176
+ WHERE tt.source LIKE ?
177
+ AND t.tag IS NOT NULL
178
+ AND TRIM(t.tag) <> ''
179
+ GROUP BY t.tag
180
+ ORDER BY frequency DESC, t.tag ASC
181
+ `);
182
+
183
+ return stmt.all(SOURCE_FILTER);
184
+ } finally {
185
+ db.close();
186
+ }
187
+ }
188
+
189
+ function aggregateByNormalizedTag(rows) {
190
+ const map = new Map();
191
+
192
+ for (const row of rows) {
193
+ const tag = typeof row.tag === "string" ? row.tag.trim() : "";
194
+ if (!tag) continue;
195
+
196
+ const normalized = normalizeKeyword(tag);
197
+ if (!normalized) continue;
198
+
199
+ if (!map.has(normalized)) {
200
+ map.set(normalized, {
201
+ keyword: tag,
202
+ normalized,
203
+ frequency: 0,
204
+ variants: new Set(),
205
+ sources: new Set()
206
+ });
207
+ }
208
+
209
+ const current = map.get(normalized);
210
+ current.frequency += Number(row.frequency) || 0;
211
+ current.variants.add(tag);
212
+ for (const source of splitSources(row.sources)) {
213
+ current.sources.add(source);
214
+ }
215
+ }
216
+
217
+ return [...map.values()].sort((a, b) => {
218
+ if (a.frequency !== b.frequency) return b.frequency - a.frequency;
219
+ return a.keyword.localeCompare(b.keyword);
220
+ });
221
+ }
222
+
223
+ function readRulesDocument(rulesPath) {
224
+ const raw = readFileSync(rulesPath, "utf8");
225
+ const parsed = JSON.parse(raw);
226
+ if (!parsed || !Array.isArray(parsed.rules)) {
227
+ throw new Error("keyword-rules.json 형식이 올바르지 않습니다.");
228
+ }
229
+ return parsed;
230
+ }
231
+
232
+ function extractLiteralWords(patternSource) {
233
+ const words = patternSource.toLowerCase().match(/[a-z0-9][a-z0-9-]{1,}/g) || [];
234
+ return words.filter((word) => !["true", "false", "null", "route", "skill"].includes(word));
235
+ }
236
+
237
+ function buildRuleIndex(rules) {
238
+ const indexed = [];
239
+
240
+ for (const rule of rules) {
241
+ const aliases = new Set();
242
+ const regexes = [];
243
+
244
+ const ruleId = typeof rule.id === "string" ? rule.id.trim() : "";
245
+ const skill = typeof rule.skill === "string" ? rule.skill.trim() : "";
246
+ const route = typeof rule.mcp_route === "string" ? rule.mcp_route.trim() : "";
247
+
248
+ if (ruleId) aliases.add(normalizeKeyword(ruleId));
249
+ if (ruleId.endsWith("-route")) aliases.add(normalizeKeyword(ruleId.slice(0, -"-route".length)));
250
+ if (ruleId.endsWith("-skill")) aliases.add(normalizeKeyword(ruleId.slice(0, -"-skill".length)));
251
+ if (skill) aliases.add(normalizeKeyword(skill));
252
+ if (route) aliases.add(normalizeKeyword(route));
253
+
254
+ for (const pattern of Array.isArray(rule.patterns) ? rule.patterns : []) {
255
+ if (!pattern || typeof pattern.source !== "string" || typeof pattern.flags !== "string") continue;
256
+ try {
257
+ regexes.push(new RegExp(pattern.source, pattern.flags));
258
+ } catch {
259
+ // 잘못된 정규식은 건너뛴다.
260
+ }
261
+
262
+ for (const token of extractLiteralWords(pattern.source)) {
263
+ aliases.add(normalizeKeyword(token));
264
+ }
265
+ }
266
+
267
+ indexed.push({
268
+ id: ruleId || "(unknown-rule)",
269
+ aliases,
270
+ regexes
271
+ });
272
+ }
273
+
274
+ return indexed;
275
+ }
276
+
277
+ function findCoveringRule(keyword, ruleIndex) {
278
+ const normalized = normalizeKeyword(keyword);
279
+ const keywordWithSpace = normalized.replace(/[-_]+/g, " ");
280
+
281
+ for (const rule of ruleIndex) {
282
+ if (rule.aliases.has(normalized)) return rule.id;
283
+
284
+ for (const regex of rule.regexes) {
285
+ regex.lastIndex = 0;
286
+ if (regex.test(normalized)) return rule.id;
287
+ regex.lastIndex = 0;
288
+ if (regex.test(keywordWithSpace)) return rule.id;
289
+ }
290
+ }
291
+
292
+ return null;
293
+ }
294
+
295
+ function classifyCandidate(keyword) {
296
+ const normalized = normalizeKeyword(keyword).replace(/_/g, "-");
297
+
298
+ if (/^tfx-[a-z0-9][a-z0-9-]*$/i.test(normalized)) {
299
+ return {
300
+ type: "skill",
301
+ label: "skill 규칙 후보",
302
+ skill: normalized,
303
+ mcpRoute: null
304
+ };
305
+ }
306
+
307
+ if (MCP_SERVICE_NAMES.has(normalized)) {
308
+ return {
309
+ type: "mcp_route",
310
+ label: "mcp_route 규칙 후보",
311
+ skill: null,
312
+ mcpRoute: MCP_SERVICE_ROUTE_MAP[normalized] || "gemini"
313
+ };
314
+ }
315
+
316
+ return {
317
+ type: "general",
318
+ label: "분류 미정",
319
+ skill: null,
320
+ mcpRoute: null
321
+ };
322
+ }
323
+
324
+ function createPatternSource(keyword) {
325
+ const tokens = normalizeKeyword(keyword)
326
+ .split(/[\s_-]+/g)
327
+ .map((item) => item.trim())
328
+ .filter(Boolean)
329
+ .map((item) => escapeRegExp(item));
330
+
331
+ if (tokens.length === 0) return "\\bkeyword\\b";
332
+ if (tokens.length === 1) return `\\b${tokens[0]}\\b`;
333
+ return `\\b${tokens.join("[\\s_-]?")}\\b`;
334
+ }
335
+
336
+ function uniqueRuleId(baseId, existingIds) {
337
+ if (!existingIds.has(baseId)) return baseId;
338
+ let index = 2;
339
+ while (existingIds.has(`${baseId}-${index}`)) {
340
+ index += 1;
341
+ }
342
+ return `${baseId}-${index}`;
343
+ }
344
+
345
+ function buildRuleFromCandidate(candidate, existingIds) {
346
+ const slug = slugifyKeyword(candidate.keyword);
347
+
348
+ if (candidate.classification.type === "skill") {
349
+ const ruleId = uniqueRuleId(`${slug}-skill`, existingIds);
350
+ existingIds.add(ruleId);
351
+ return {
352
+ id: ruleId,
353
+ patterns: [
354
+ {
355
+ source: createPatternSource(candidate.keyword),
356
+ flags: "i"
357
+ }
358
+ ],
359
+ skill: candidate.classification.skill,
360
+ priority: 20,
361
+ supersedes: [],
362
+ exclusive: false,
363
+ state: null,
364
+ mcp_route: null
365
+ };
366
+ }
367
+
368
+ if (candidate.classification.type === "mcp_route") {
369
+ const ruleId = uniqueRuleId(`${slug}-route`, existingIds);
370
+ existingIds.add(ruleId);
371
+ return {
372
+ id: ruleId,
373
+ patterns: [
374
+ {
375
+ source: createPatternSource(candidate.keyword),
376
+ flags: "i"
377
+ }
378
+ ],
379
+ skill: null,
380
+ priority: 20,
381
+ supersedes: [],
382
+ exclusive: false,
383
+ state: null,
384
+ mcp_route: candidate.classification.mcpRoute
385
+ };
386
+ }
387
+
388
+ return null;
389
+ }
390
+
391
+ function formatSources(allTags) {
392
+ const sourceSet = new Set();
393
+ for (const tag of allTags) {
394
+ for (const source of tag.sources) sourceSet.add(source);
395
+ }
396
+ const sorted = [...sourceSet].sort((a, b) => a.localeCompare(b));
397
+ if (sorted.length === 0) return "(없음)";
398
+ return sorted.join(", ");
399
+ }
400
+
401
+ function printAnalysis({
402
+ dbPathDisplay,
403
+ sourceDisplay,
404
+ totalTags,
405
+ coveredCount,
406
+ threshold,
407
+ candidates,
408
+ covered
409
+ }) {
410
+ console.log("=== keyword-rules-expander 분석 결과 ===");
411
+ console.log("");
412
+ console.log(`DB: ${dbPathDisplay}`);
413
+ console.log(`추출 소스: ${sourceDisplay}`);
414
+ console.log(`총 태그: ${totalTags}개, 기존 규칙 매칭: ${coveredCount}개`);
415
+ console.log("");
416
+
417
+ console.log(`--- 새 규칙 후보 (threshold: ${threshold}) ---`);
418
+ if (candidates.length === 0) {
419
+ console.log(" (없음)");
420
+ } else {
421
+ for (const item of candidates) {
422
+ console.log(` ${item.keyword} (${item.frequency}회) → ${item.classification.label}`);
423
+ }
424
+ }
425
+
426
+ console.log("");
427
+ console.log("--- 이미 커버됨 (스킵) ---");
428
+ if (covered.length === 0) {
429
+ console.log(" (없음)");
430
+ } else {
431
+ for (const item of covered) {
432
+ console.log(` ${item.keyword} (${item.frequency}회) → ${item.ruleId} 규칙 있음`);
433
+ }
434
+ }
435
+ }
436
+
437
+ function main() {
438
+ const args = parseArgs(process.argv.slice(2));
439
+ if (args.help) {
440
+ printUsage();
441
+ return;
442
+ }
443
+
444
+ const resolvedDbPath = resolve(expandHomePath(args.dbPath));
445
+ const rulesDoc = readRulesDocument(RULES_PATH);
446
+ const ruleIndex = buildRuleIndex(rulesDoc.rules);
447
+
448
+ const rawRows = fetchTagFrequencyRows(resolvedDbPath);
449
+ const tags = aggregateByNormalizedTag(rawRows);
450
+
451
+ const covered = [];
452
+ const candidates = [];
453
+
454
+ for (const tag of tags) {
455
+ const matchedRuleId = findCoveringRule(tag.keyword, ruleIndex);
456
+
457
+ if (matchedRuleId) {
458
+ covered.push({
459
+ keyword: tag.keyword,
460
+ frequency: tag.frequency,
461
+ ruleId: matchedRuleId
462
+ });
463
+ continue;
464
+ }
465
+
466
+ if (tag.frequency < args.threshold) continue;
467
+
468
+ candidates.push({
469
+ keyword: tag.keyword,
470
+ normalized: tag.normalized,
471
+ frequency: tag.frequency,
472
+ classification: classifyCandidate(tag.keyword)
473
+ });
474
+ }
475
+
476
+ printAnalysis({
477
+ dbPathDisplay: toDisplayPath(resolvedDbPath),
478
+ sourceDisplay: formatSources(tags),
479
+ totalTags: tags.length,
480
+ coveredCount: covered.length,
481
+ threshold: args.threshold,
482
+ candidates,
483
+ covered
484
+ });
485
+
486
+ if (!args.apply) return;
487
+
488
+ const existingIds = new Set(
489
+ rulesDoc.rules
490
+ .map((rule) => (typeof rule.id === "string" ? rule.id.trim() : ""))
491
+ .filter(Boolean)
492
+ );
493
+
494
+ const autoApplicable = candidates.filter((item) => item.classification.type !== "general");
495
+ const manualReviewCount = candidates.length - autoApplicable.length;
496
+
497
+ const newRules = autoApplicable
498
+ .map((candidate) => buildRuleFromCandidate(candidate, existingIds))
499
+ .filter(Boolean);
500
+
501
+ if (newRules.length > 0) {
502
+ rulesDoc.rules.push(...newRules);
503
+ writeFileSync(RULES_PATH, `${JSON.stringify(rulesDoc, null, 2)}\n`, "utf8");
504
+ }
505
+
506
+ console.log("");
507
+ console.log("--- 적용 결과 ---");
508
+ console.log(` 추가된 규칙: ${newRules.length}개`);
509
+ if (manualReviewCount > 0) {
510
+ console.log(` 분류 미정(수동 검토): ${manualReviewCount}개`);
511
+ }
512
+ console.log(` 저장 파일: ${RULES_PATH}`);
513
+ }
514
+
515
+ try {
516
+ main();
517
+ } catch (error) {
518
+ console.error(`[keyword-rules-expander] 오류: ${error.message}`);
519
+ process.exit(1);
520
+ }
521
+
@@ -0,0 +1,168 @@
1
+ import { readFileSync } from "node:fs";
2
+
3
+ const VALID_MCP_ROUTES = new Set(["codex", "gemini", "claude"]);
4
+
5
+ function logRuleError(message, error) {
6
+ if (error) {
7
+ console.error(`[triflux-keyword-rules] ${message}: ${error.message}`);
8
+ return;
9
+ }
10
+ console.error(`[triflux-keyword-rules] ${message}`);
11
+ }
12
+
13
+ function normalizePattern(pattern) {
14
+ if (!pattern || typeof pattern.source !== "string") return null;
15
+ if (typeof pattern.flags !== "string") return null;
16
+ return { source: pattern.source, flags: pattern.flags };
17
+ }
18
+
19
+ function normalizeState(state) {
20
+ if (state == null) return null;
21
+ if (typeof state !== "object") return null;
22
+ if (typeof state.activate !== "boolean") return null;
23
+ if (typeof state.name !== "string" || !state.name.trim()) return null;
24
+ return { activate: state.activate, name: state.name.trim() };
25
+ }
26
+
27
+ function normalizeRule(rule) {
28
+ if (!rule || typeof rule !== "object") return null;
29
+ if (typeof rule.id !== "string" || !rule.id.trim()) return null;
30
+ if (!Array.isArray(rule.patterns) || rule.patterns.length === 0) return null;
31
+ if (typeof rule.priority !== "number" || !Number.isFinite(rule.priority)) return null;
32
+
33
+ const patterns = rule.patterns.map(normalizePattern).filter(Boolean);
34
+ if (patterns.length === 0) return null;
35
+
36
+ const skill = typeof rule.skill === "string" && rule.skill.trim() ? rule.skill.trim() : null;
37
+ const action = typeof rule.action === "string" && rule.action.trim() ? rule.action.trim() : null;
38
+ const mcpRoute = typeof rule.mcp_route === "string" && VALID_MCP_ROUTES.has(rule.mcp_route)
39
+ ? rule.mcp_route
40
+ : null;
41
+
42
+ if (!skill && !mcpRoute && !action) return null;
43
+
44
+ const supersedes = Array.isArray(rule.supersedes)
45
+ ? rule.supersedes.filter((id) => typeof id === "string" && id.trim()).map((id) => id.trim())
46
+ : [];
47
+
48
+ const state = normalizeState(rule.state);
49
+ if (rule.state != null && state == null) return null;
50
+
51
+ return {
52
+ id: rule.id.trim(),
53
+ patterns,
54
+ skill,
55
+ action: rule.action || null,
56
+ priority: rule.priority,
57
+ supersedes,
58
+ exclusive: rule.exclusive === true,
59
+ state,
60
+ mcp_route: mcpRoute
61
+ };
62
+ }
63
+
64
+ // 외부 JSON 규칙 로드 + 스키마 검증
65
+ export function loadRules(rulesPath) {
66
+ try {
67
+ const raw = readFileSync(rulesPath, "utf8");
68
+ const parsed = JSON.parse(raw);
69
+ if (!parsed || !Array.isArray(parsed.rules)) {
70
+ logRuleError(`규칙 형식이 올바르지 않습니다: ${rulesPath}`);
71
+ return [];
72
+ }
73
+
74
+ const normalized = parsed.rules.map(normalizeRule).filter(Boolean);
75
+ return normalized;
76
+ } catch (error) {
77
+ logRuleError(`규칙 파일을 읽을 수 없습니다: ${rulesPath}`, error);
78
+ return [];
79
+ }
80
+ }
81
+
82
+ // pattern.source / flags를 RegExp로 컴파일
83
+ export function compileRules(rules) {
84
+ try {
85
+ return rules.map((rule) => ({
86
+ ...rule,
87
+ compiledPatterns: rule.patterns.map((pattern) => new RegExp(pattern.source, pattern.flags))
88
+ }));
89
+ } catch (error) {
90
+ // fail-open: 정규식 오류 시 전체 비활성
91
+ logRuleError("정규식 컴파일 실패", error);
92
+ return [];
93
+ }
94
+ }
95
+
96
+ // 입력 텍스트에서 매칭된 규칙 목록 반환
97
+ export function matchRules(compiledRules, cleanText) {
98
+ if (!Array.isArray(compiledRules) || typeof cleanText !== "string" || !cleanText) {
99
+ return [];
100
+ }
101
+
102
+ const matches = [];
103
+
104
+ for (const rule of compiledRules) {
105
+ if (!Array.isArray(rule.compiledPatterns) || rule.compiledPatterns.length === 0) {
106
+ continue;
107
+ }
108
+
109
+ const matched = rule.compiledPatterns.some((pattern) => {
110
+ pattern.lastIndex = 0;
111
+ return pattern.test(cleanText);
112
+ });
113
+
114
+ if (!matched) continue;
115
+
116
+ matches.push({
117
+ id: rule.id,
118
+ skill: rule.skill,
119
+ action: rule.action || null,
120
+ priority: rule.priority,
121
+ supersedes: rule.supersedes || [],
122
+ exclusive: rule.exclusive === true,
123
+ state: rule.state || null,
124
+ mcp_route: rule.mcp_route || null
125
+ });
126
+ }
127
+
128
+ return matches;
129
+ }
130
+
131
+ // priority 정렬 + supersedes + exclusive 처리
132
+ export function resolveConflicts(matches) {
133
+ try {
134
+ if (!Array.isArray(matches) || matches.length === 0) return [];
135
+
136
+ const sorted = [...matches].sort((a, b) => {
137
+ if (a.priority !== b.priority) return a.priority - b.priority;
138
+ return String(a.id).localeCompare(String(b.id));
139
+ });
140
+
141
+ const deduped = [];
142
+ const seen = new Set();
143
+ for (const match of sorted) {
144
+ if (seen.has(match.id)) continue;
145
+ deduped.push(match);
146
+ seen.add(match.id);
147
+ }
148
+
149
+ const superseded = new Set();
150
+ const resolved = [];
151
+
152
+ for (const match of deduped) {
153
+ if (superseded.has(match.id)) continue;
154
+ resolved.push(match);
155
+ for (const targetId of match.supersedes || []) {
156
+ superseded.add(targetId);
157
+ }
158
+ }
159
+
160
+ const exclusiveMatch = resolved.find((match) => match.exclusive === true);
161
+ if (exclusiveMatch) return [exclusiveMatch];
162
+
163
+ return resolved;
164
+ } catch (error) {
165
+ logRuleError("규칙 충돌 해결 실패", error);
166
+ return [];
167
+ }
168
+ }