@triflux/core 10.50.1 → 10.50.2
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/hub/index.mjs +2 -8
- package/hub/lib/cto-env.mjs +0 -23
- package/hub/paths.mjs +0 -6
- package/hub/pipeline/index.mjs +0 -190
- package/hub/pipeline/transitions.mjs +0 -10
- package/hub/reflexion.mjs +1 -84
- package/hud/cli-policy.mjs +1 -5
- package/hud/colors.mjs +0 -13
- package/hud/constants.mjs +4 -135
- package/hud/context-monitor.mjs +3 -64
- package/hud/hud-qos-status.mjs +43 -225
- package/hud/providers/claude.mjs +4 -16
- package/hud/providers/gemini.mjs +7 -698
- package/hud/renderers.mjs +40 -294
- package/hud/terminal.mjs +1 -63
- package/hud/utils.mjs +0 -24
- package/package.json +1 -2
- package/scripts/lib/gemini-profiles.mjs +1 -1
- package/scripts/lib/legacy-mcp-cleanup.mjs +14 -3
- package/scripts/lib/psmux-info.mjs +18 -49
- package/scripts/lib/skill-template.mjs +0 -26
- package/hub/codex-compat.mjs +0 -12
- package/hub/fullcycle.mjs +0 -113
- package/hub/gemini-adapter.mjs +0 -174
- package/hub/intent.mjs +0 -321
- package/hub/lib/mcp-response-cache.mjs +0 -239
- package/hub/lib/path-utils.mjs +0 -165
- package/hub/lib/timeline-adapter.mjs +0 -190
- package/hub/pipeline/gates/confidence.mjs +0 -75
- package/hub/pipeline/gates/consensus.mjs +0 -106
- package/hub/pipeline/gates/index.mjs +0 -9
- package/hub/pipeline/gates/selfcheck.mjs +0 -120
- package/hub/quality/deslop.mjs +0 -274
- package/hub/research.mjs +0 -155
- package/hub/role-control-events.mjs +0 -113
- package/hub/routing/complexity.mjs +0 -269
- package/hub/routing/index.mjs +0 -122
- package/hub/routing/q-learning.mjs +0 -387
- package/hub/token-mode.mjs +0 -228
- package/hud/mission-board.mjs +0 -56
- package/hud/providers/cto.mjs +0 -76
- package/scripts/lib/agent-json.mjs +0 -27
- package/scripts/lib/async.mjs +0 -174
- package/scripts/lib/cli-agy.mjs +0 -85
- package/scripts/lib/cli-codex.mjs +0 -62
- package/scripts/lib/cli-gemini.mjs +0 -37
- package/scripts/lib/env.mjs +0 -14
- package/scripts/lib/hub.mjs +0 -48
- package/scripts/lib/pid.mjs +0 -63
- package/scripts/lib/quota.mjs +0 -47
- package/scripts/lib/skill-state.mjs +0 -220
- package/scripts/lib/team.mjs +0 -75
- package/scripts/lib/timeout.mjs +0 -66
- package/scripts/lib/tmp.mjs +0 -26
- package/scripts/lib/toml.mjs +0 -132
package/hub/quality/deslop.mjs
DELETED
|
@@ -1,274 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Anti-Slop Code Pass
|
|
3
|
-
* AI 생성 코드에서 불필요한 요소를 자동 탐지/제거하는 정적 분석 모듈
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
import { readdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
7
|
-
import { join } from "node:path";
|
|
8
|
-
|
|
9
|
-
/** @type {ReadonlyArray<{type: string, pattern: RegExp, severity: string, autoFixable: boolean, multiline: boolean}>} */
|
|
10
|
-
export const SLOP_PATTERNS = Object.freeze([
|
|
11
|
-
{
|
|
12
|
-
type: "trivial_comment",
|
|
13
|
-
pattern: /^\s*\/\/\s*(import|define|set|get|return|export)\s/i,
|
|
14
|
-
severity: "low",
|
|
15
|
-
autoFixable: true,
|
|
16
|
-
multiline: false,
|
|
17
|
-
},
|
|
18
|
-
{
|
|
19
|
-
type: "empty_catch",
|
|
20
|
-
pattern: /catch\s*\([^)]*\)\s*\{\s*\}/,
|
|
21
|
-
severity: "med",
|
|
22
|
-
autoFixable: false,
|
|
23
|
-
multiline: true,
|
|
24
|
-
},
|
|
25
|
-
{
|
|
26
|
-
type: "console_debug",
|
|
27
|
-
pattern: /^\s*console\.(log|debug|info)\(/,
|
|
28
|
-
severity: "low",
|
|
29
|
-
autoFixable: true,
|
|
30
|
-
multiline: false,
|
|
31
|
-
},
|
|
32
|
-
{
|
|
33
|
-
type: "useless_jsdoc",
|
|
34
|
-
pattern: /\/\*\*\s*\n\s*\*\s*\n\s*\*\//,
|
|
35
|
-
severity: "low",
|
|
36
|
-
autoFixable: true,
|
|
37
|
-
multiline: true,
|
|
38
|
-
},
|
|
39
|
-
{
|
|
40
|
-
type: "rethrow_only",
|
|
41
|
-
pattern: /catch\s*\((\w+)\)\s*\{\s*throw\s+\1\s*;?\s*\}/,
|
|
42
|
-
severity: "med",
|
|
43
|
-
autoFixable: false,
|
|
44
|
-
multiline: true,
|
|
45
|
-
},
|
|
46
|
-
{
|
|
47
|
-
type: "redundant_type",
|
|
48
|
-
pattern:
|
|
49
|
-
/:\s*(string|number|boolean)\s*=\s*('[^']*'|"[^"]*"|\d+|true|false)/,
|
|
50
|
-
severity: "low",
|
|
51
|
-
autoFixable: false,
|
|
52
|
-
multiline: false,
|
|
53
|
-
},
|
|
54
|
-
{
|
|
55
|
-
type: "commented_code",
|
|
56
|
-
pattern:
|
|
57
|
-
/^\s*\/\/\s*(const |let |var |function |class |if\s*\(|for\s*\(|while\s*\(|return |await )/,
|
|
58
|
-
severity: "low",
|
|
59
|
-
autoFixable: true,
|
|
60
|
-
multiline: false,
|
|
61
|
-
},
|
|
62
|
-
]);
|
|
63
|
-
|
|
64
|
-
const SEVERITY_WEIGHT = { low: 2, med: 5 };
|
|
65
|
-
|
|
66
|
-
/**
|
|
67
|
-
* 파일 내용에서 slop 패턴 탐지
|
|
68
|
-
* @param {string} content - 파일 내용
|
|
69
|
-
* @param {string} [filePath] - 파일 경로 (보고용)
|
|
70
|
-
* @returns {{ issues: Array<{line: number, type: string, severity: string, suggestion: string, text: string, autoFixable: boolean}>, score: number }}
|
|
71
|
-
*/
|
|
72
|
-
export function detectSlop(content, filePath = "") {
|
|
73
|
-
const lines = content.split("\n");
|
|
74
|
-
const issues = [];
|
|
75
|
-
|
|
76
|
-
for (const sp of SLOP_PATTERNS) {
|
|
77
|
-
if (sp.multiline) continue;
|
|
78
|
-
for (let i = 0; i < lines.length; i++) {
|
|
79
|
-
if (sp.pattern.test(lines[i])) {
|
|
80
|
-
issues.push({
|
|
81
|
-
line: i + 1,
|
|
82
|
-
type: sp.type,
|
|
83
|
-
severity: sp.severity,
|
|
84
|
-
suggestion: `${sp.type} 패턴 감지`,
|
|
85
|
-
text: lines[i].trim(),
|
|
86
|
-
autoFixable: sp.autoFixable,
|
|
87
|
-
file: filePath,
|
|
88
|
-
});
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
for (const sp of SLOP_PATTERNS) {
|
|
94
|
-
if (!sp.multiline) continue;
|
|
95
|
-
const regex = new RegExp(
|
|
96
|
-
sp.pattern.source,
|
|
97
|
-
sp.pattern.flags.replace("g", "") + "g",
|
|
98
|
-
);
|
|
99
|
-
let match;
|
|
100
|
-
while ((match = regex.exec(content)) !== null) {
|
|
101
|
-
const line = content.substring(0, match.index).split("\n").length;
|
|
102
|
-
issues.push({
|
|
103
|
-
line,
|
|
104
|
-
type: sp.type,
|
|
105
|
-
severity: sp.severity,
|
|
106
|
-
suggestion: `${sp.type} 패턴 감지`,
|
|
107
|
-
text: match[0].split("\n")[0].trim(),
|
|
108
|
-
autoFixable: sp.autoFixable,
|
|
109
|
-
file: filePath,
|
|
110
|
-
});
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
issues.sort((a, b) => a.line - b.line);
|
|
115
|
-
|
|
116
|
-
const totalPenalty = issues.reduce(
|
|
117
|
-
(sum, i) => sum + (SEVERITY_WEIGHT[i.severity] || 2),
|
|
118
|
-
0,
|
|
119
|
-
);
|
|
120
|
-
const score = Math.max(0, 100 - totalPenalty);
|
|
121
|
-
|
|
122
|
-
return { issues, score };
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
/**
|
|
126
|
-
* 자동 수정 (safe transforms만)
|
|
127
|
-
* @param {string} content - 파일 내용
|
|
128
|
-
* @param {Array<{type: string, autoFixable: boolean}>} issues - detectSlop 결과
|
|
129
|
-
* @returns {{ fixed: string, applied: number, skipped: number }}
|
|
130
|
-
*/
|
|
131
|
-
export function autoFixSlop(content, issues) {
|
|
132
|
-
if (!issues || issues.length === 0)
|
|
133
|
-
return { fixed: content, applied: 0, skipped: 0 };
|
|
134
|
-
|
|
135
|
-
const fixable = issues.filter((i) => i.autoFixable);
|
|
136
|
-
const skipped = issues.length - fixable.length;
|
|
137
|
-
|
|
138
|
-
if (fixable.length === 0) return { fixed: content, applied: 0, skipped };
|
|
139
|
-
|
|
140
|
-
let fixed = content;
|
|
141
|
-
let applied = 0;
|
|
142
|
-
|
|
143
|
-
// Multi-line: useless_jsdoc 제거
|
|
144
|
-
if (fixable.some((i) => i.type === "useless_jsdoc")) {
|
|
145
|
-
const matches = fixed.match(/\/\*\*\s*\n\s*\*\s*\n\s*\*\/\n?/g);
|
|
146
|
-
if (matches) {
|
|
147
|
-
fixed = fixed.replace(/\/\*\*\s*\n\s*\*\s*\n\s*\*\/\n?/g, "");
|
|
148
|
-
applied += matches.length;
|
|
149
|
-
}
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
// Line-level: trivial_comment, console_debug, commented_code 제거
|
|
153
|
-
const lineTypes = new Set(
|
|
154
|
-
fixable
|
|
155
|
-
.filter((i) =>
|
|
156
|
-
["trivial_comment", "console_debug", "commented_code"].includes(i.type),
|
|
157
|
-
)
|
|
158
|
-
.map((i) => i.type),
|
|
159
|
-
);
|
|
160
|
-
|
|
161
|
-
if (lineTypes.size > 0) {
|
|
162
|
-
const linePatterns = SLOP_PATTERNS.filter((p) => lineTypes.has(p.type));
|
|
163
|
-
const lines = fixed.split("\n");
|
|
164
|
-
const result = [];
|
|
165
|
-
for (const line of lines) {
|
|
166
|
-
let remove = false;
|
|
167
|
-
for (const p of linePatterns) {
|
|
168
|
-
if (p.pattern.test(line)) {
|
|
169
|
-
remove = true;
|
|
170
|
-
applied++;
|
|
171
|
-
break;
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
if (!remove) result.push(line);
|
|
175
|
-
}
|
|
176
|
-
fixed = result.join("\n");
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
return { fixed, applied, skipped };
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
function matchesGlob(filePath, pattern) {
|
|
183
|
-
const normalized = "/" + filePath.replace(/\\/g, "/");
|
|
184
|
-
|
|
185
|
-
// **/*.ext → 확장자 매칭
|
|
186
|
-
if (pattern.startsWith("**/*.")) {
|
|
187
|
-
const ext = pattern.slice(4);
|
|
188
|
-
return normalized.endsWith(ext);
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
// *.ext → 확장자 매칭 (디렉토리 무관)
|
|
192
|
-
if (pattern.startsWith("*.") && !pattern.includes("/")) {
|
|
193
|
-
const ext = pattern.slice(1);
|
|
194
|
-
return normalized.endsWith(ext);
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
// **/dir/** → 디렉토리 포함 여부
|
|
198
|
-
const dirMatch = pattern.match(/^\*\*\/([^*]+)\/\*\*$/);
|
|
199
|
-
if (dirMatch) {
|
|
200
|
-
return normalized.includes("/" + dirMatch[1] + "/");
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
return false;
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
/**
|
|
207
|
-
* 디렉토리 전체 스캔
|
|
208
|
-
* @param {string} dirPath - 스캔 대상 디렉토리
|
|
209
|
-
* @param {object} [opts]
|
|
210
|
-
* @param {string[]} [opts.include] - 포함할 glob 패턴
|
|
211
|
-
* @param {string[]} [opts.exclude] - 제외할 glob 패턴
|
|
212
|
-
* @param {boolean} [opts.autoFix] - 자동 수정 여부
|
|
213
|
-
* @returns {Promise<{ files: Array<{path: string, issues: Array, score: number}>, summary: object }>}
|
|
214
|
-
*/
|
|
215
|
-
export async function scanDirectory(dirPath, opts = {}) {
|
|
216
|
-
const {
|
|
217
|
-
include = ["**/*.mjs", "**/*.js", "**/*.ts"],
|
|
218
|
-
exclude = ["**/node_modules/**", "**/dist/**", "**/.git/**"],
|
|
219
|
-
autoFix = false,
|
|
220
|
-
} = opts;
|
|
221
|
-
|
|
222
|
-
const entries = await readdir(dirPath, { recursive: true });
|
|
223
|
-
const files = [];
|
|
224
|
-
|
|
225
|
-
for (const entry of entries) {
|
|
226
|
-
const normalized = entry.replace(/\\/g, "/");
|
|
227
|
-
const fullPath = join(dirPath, entry);
|
|
228
|
-
|
|
229
|
-
let st;
|
|
230
|
-
try {
|
|
231
|
-
st = await stat(fullPath);
|
|
232
|
-
} catch {
|
|
233
|
-
continue;
|
|
234
|
-
}
|
|
235
|
-
if (!st.isFile()) continue;
|
|
236
|
-
|
|
237
|
-
const included = include.some((p) => matchesGlob(normalized, p));
|
|
238
|
-
const excluded = exclude.some((p) => matchesGlob(normalized, p));
|
|
239
|
-
if (!included || excluded) continue;
|
|
240
|
-
|
|
241
|
-
const fileContent = await readFile(fullPath, "utf-8");
|
|
242
|
-
const { issues, score } = detectSlop(fileContent, normalized);
|
|
243
|
-
|
|
244
|
-
if (autoFix && issues.length > 0) {
|
|
245
|
-
const { fixed, applied } = autoFixSlop(fileContent, issues);
|
|
246
|
-
if (applied > 0) await writeFile(fullPath, fixed, "utf-8");
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
files.push({ path: normalized, issues, score });
|
|
250
|
-
}
|
|
251
|
-
|
|
252
|
-
const totalIssues = files.reduce((sum, f) => sum + f.issues.length, 0);
|
|
253
|
-
const avgScore =
|
|
254
|
-
files.length > 0
|
|
255
|
-
? Math.round(files.reduce((sum, f) => sum + f.score, 0) / files.length)
|
|
256
|
-
: 100;
|
|
257
|
-
|
|
258
|
-
const byType = {};
|
|
259
|
-
for (const f of files) {
|
|
260
|
-
for (const issue of f.issues) {
|
|
261
|
-
byType[issue.type] = (byType[issue.type] || 0) + 1;
|
|
262
|
-
}
|
|
263
|
-
}
|
|
264
|
-
|
|
265
|
-
return {
|
|
266
|
-
files,
|
|
267
|
-
summary: {
|
|
268
|
-
totalFiles: files.length,
|
|
269
|
-
totalIssues,
|
|
270
|
-
averageScore: avgScore,
|
|
271
|
-
byType,
|
|
272
|
-
},
|
|
273
|
-
};
|
|
274
|
-
}
|
package/hub/research.mjs
DELETED
|
@@ -1,155 +0,0 @@
|
|
|
1
|
-
// hub/research.mjs — 자율 웹 리서치 엔진 코어
|
|
2
|
-
// 검색 쿼리 생성 → 결과 정규화 → 보고서 빌드 → 저장
|
|
3
|
-
|
|
4
|
-
import { mkdirSync, writeFileSync } from "node:fs";
|
|
5
|
-
import { join, resolve } from "node:path";
|
|
6
|
-
import { TFX_REPORTS_DIR } from "./paths.mjs";
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* @experimental 런타임 미연결 — 향후 통합 예정
|
|
10
|
-
* @experimental
|
|
11
|
-
* 주제에서 검색 쿼리 3-5개를 자동 생성한다.
|
|
12
|
-
* 한국어 주제 → 한국어 + 영어 혼합, 영어 주제 → 영어 쿼리.
|
|
13
|
-
* @param {string} topic - 리서치 주제
|
|
14
|
-
* @param {'ko'|'en'|'auto'} [lang='auto'] - 언어 힌트
|
|
15
|
-
* @returns {string[]} 검색 쿼리 배열
|
|
16
|
-
*/
|
|
17
|
-
export function generateQueries(topic, lang = "auto") {
|
|
18
|
-
if (!topic || typeof topic !== "string" || !topic.trim()) return [];
|
|
19
|
-
|
|
20
|
-
const t = topic.trim();
|
|
21
|
-
const detectedLang = lang === "auto" ? detectLang(t) : lang;
|
|
22
|
-
|
|
23
|
-
if (detectedLang === "ko") {
|
|
24
|
-
return [
|
|
25
|
-
`${t} 정리`,
|
|
26
|
-
`${t} 비교 분석`,
|
|
27
|
-
`${t} 최신 동향 ${new Date().getFullYear()}`,
|
|
28
|
-
`${toEnglishQuery(t)} overview`,
|
|
29
|
-
`${toEnglishQuery(t)} comparison ${new Date().getFullYear()}`,
|
|
30
|
-
];
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
return [
|
|
34
|
-
`${t} overview`,
|
|
35
|
-
`${t} comparison`,
|
|
36
|
-
`${t} best practices ${new Date().getFullYear()}`,
|
|
37
|
-
`${t} pros and cons`,
|
|
38
|
-
];
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
* 검색 원시 결과를 정규화한다. 중복 URL 제거 + 빈/null 필터링.
|
|
43
|
-
* @param {Array<object|null|undefined>} rawResults - 검색 엔진 원시 결과
|
|
44
|
-
* @returns {Array<{title: string, url: string, snippet: string}>}
|
|
45
|
-
*/
|
|
46
|
-
export function normalizeResults(rawResults) {
|
|
47
|
-
if (!Array.isArray(rawResults)) return [];
|
|
48
|
-
|
|
49
|
-
const seen = new Set();
|
|
50
|
-
const out = [];
|
|
51
|
-
|
|
52
|
-
for (const r of rawResults) {
|
|
53
|
-
if (!r || typeof r !== "object") continue;
|
|
54
|
-
const url = (r.url || r.link || "").trim();
|
|
55
|
-
const title = (r.title || r.name || "").trim();
|
|
56
|
-
const snippet = (r.snippet || r.description || r.content || "").trim();
|
|
57
|
-
|
|
58
|
-
if (!url || seen.has(url)) continue;
|
|
59
|
-
seen.add(url);
|
|
60
|
-
out.push({ title, url, snippet });
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
return out;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
/**
|
|
67
|
-
* 리서치 보고서를 마크다운으로 빌드한다.
|
|
68
|
-
* @param {string} topic - 리서치 주제
|
|
69
|
-
* @param {string[]} findings - 핵심 발견 목록
|
|
70
|
-
* @param {Array<{title: string, url: string, snippet: string}>} sources - 출처 목록
|
|
71
|
-
* @returns {string} 마크다운 문자열
|
|
72
|
-
*/
|
|
73
|
-
export function buildReport(topic, findings, sources) {
|
|
74
|
-
const date = new Date().toISOString().split("T")[0];
|
|
75
|
-
const findingsSection = (findings || [])
|
|
76
|
-
.map((f, i) => `${i + 1}. ${f}`)
|
|
77
|
-
.join("\n");
|
|
78
|
-
const sourcesSection = (sources || [])
|
|
79
|
-
.map(
|
|
80
|
-
(s) =>
|
|
81
|
-
`- [${s.title || s.url}](${s.url})${s.snippet ? ` — ${s.snippet}` : ""}`,
|
|
82
|
-
)
|
|
83
|
-
.join("\n");
|
|
84
|
-
|
|
85
|
-
return `# Research: ${topic}
|
|
86
|
-
Date: ${date}
|
|
87
|
-
|
|
88
|
-
## Executive Summary
|
|
89
|
-
${topic}에 대한 자동 리서치 결과입니다.
|
|
90
|
-
|
|
91
|
-
## Key Findings
|
|
92
|
-
${findingsSection || "_발견 없음_"}
|
|
93
|
-
|
|
94
|
-
## Actionable Recommendations
|
|
95
|
-
리서치 결과를 바탕으로 다음 단계를 검토하세요.
|
|
96
|
-
|
|
97
|
-
## Sources
|
|
98
|
-
${sourcesSection || "_출처 없음_"}
|
|
99
|
-
`;
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
/**
|
|
103
|
-
* 보고서를 .tfx/reports/research-{timestamp}.md에 저장한다.
|
|
104
|
-
* @param {string} topic - 리서치 주제 (파일명 생성용)
|
|
105
|
-
* @param {string} content - 마크다운 보고서 내용
|
|
106
|
-
* @param {string} [baseDir=process.cwd()] - 프로젝트 루트 경로
|
|
107
|
-
* @returns {string} 저장된 파일 경로
|
|
108
|
-
*/
|
|
109
|
-
export function saveReport(topic, content, baseDir = process.cwd()) {
|
|
110
|
-
const dir = join(baseDir, TFX_REPORTS_DIR);
|
|
111
|
-
const resolvedDir = resolve(dir);
|
|
112
|
-
const expectedBase = resolve(baseDir || TFX_REPORTS_DIR);
|
|
113
|
-
if (!resolvedDir.startsWith(expectedBase)) {
|
|
114
|
-
throw new Error("Invalid report directory: path traversal detected");
|
|
115
|
-
}
|
|
116
|
-
mkdirSync(dir, { recursive: true });
|
|
117
|
-
|
|
118
|
-
const ts = new Date()
|
|
119
|
-
.toISOString()
|
|
120
|
-
.replace(/[:.]/g, "-")
|
|
121
|
-
.replace("T", "_")
|
|
122
|
-
.slice(0, 19);
|
|
123
|
-
const slug = (topic || "untitled")
|
|
124
|
-
.replace(/[^a-zA-Z0-9가-힣\s-]/g, "")
|
|
125
|
-
.replace(/\s+/g, "-")
|
|
126
|
-
.slice(0, 40)
|
|
127
|
-
.toLowerCase();
|
|
128
|
-
const filename = `research-${ts}-${slug}.md`;
|
|
129
|
-
const filepath = join(dir, filename);
|
|
130
|
-
|
|
131
|
-
writeFileSync(filepath, content, "utf-8");
|
|
132
|
-
return filepath;
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
// ── internal helpers ──
|
|
136
|
-
|
|
137
|
-
/**
|
|
138
|
-
* 텍스트에 한글이 포함되어 있으면 'ko', 아니면 'en'
|
|
139
|
-
* @param {string} text
|
|
140
|
-
* @returns {'ko'|'en'}
|
|
141
|
-
*/
|
|
142
|
-
function detectLang(text) {
|
|
143
|
-
return /[가-힣]/.test(text) ? "ko" : "en";
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
/**
|
|
147
|
-
* 한국어 토픽에서 영어 검색 쿼리용 문자열 추출.
|
|
148
|
-
* 영문/숫자만 남기고, 없으면 원문 그대로 반환.
|
|
149
|
-
* @param {string} text
|
|
150
|
-
* @returns {string}
|
|
151
|
-
*/
|
|
152
|
-
function toEnglishQuery(text) {
|
|
153
|
-
const eng = text.replace(/[가-힣\s]+/g, " ").trim();
|
|
154
|
-
return eng || text;
|
|
155
|
-
}
|
|
@@ -1,113 +0,0 @@
|
|
|
1
|
-
const EVENT_NAMES = new Set(["capability.degraded", "drift.detected"]);
|
|
2
|
-
const CONTROL_ACTIONS = new Set([
|
|
3
|
-
"log_only",
|
|
4
|
-
"candidate_exclude",
|
|
5
|
-
"adapter_disable",
|
|
6
|
-
"role_failover",
|
|
7
|
-
"dispatch_block",
|
|
8
|
-
"manager_disable",
|
|
9
|
-
]);
|
|
10
|
-
const OPTIONAL_FIELDS = [
|
|
11
|
-
"project_id",
|
|
12
|
-
"role_key_wire",
|
|
13
|
-
"candidate_agent_id",
|
|
14
|
-
"transport_id",
|
|
15
|
-
"capability",
|
|
16
|
-
"expected_version",
|
|
17
|
-
"observed_version",
|
|
18
|
-
"trace_id",
|
|
19
|
-
"error_code",
|
|
20
|
-
];
|
|
21
|
-
|
|
22
|
-
export const ROLE_CONTROL_EVENT_SCHEMA_VERSION = "tfx.role-control-event.v1";
|
|
23
|
-
export const ROLE_CONTROL_EVENT_FIELDS = Object.freeze([
|
|
24
|
-
"schema_version",
|
|
25
|
-
"event",
|
|
26
|
-
"event_id",
|
|
27
|
-
"module",
|
|
28
|
-
"dur_ms",
|
|
29
|
-
"control_action",
|
|
30
|
-
"reason_code",
|
|
31
|
-
...OPTIONAL_FIELDS,
|
|
32
|
-
]);
|
|
33
|
-
|
|
34
|
-
function eventError(code, message = code) {
|
|
35
|
-
const error = new Error(message);
|
|
36
|
-
error.code = code;
|
|
37
|
-
return error;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
function defaultEventId() {
|
|
41
|
-
return (
|
|
42
|
-
globalThis.crypto?.randomUUID?.() ??
|
|
43
|
-
`evt_${Math.random().toString(36).slice(2)}`
|
|
44
|
-
);
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
function validateRequired(event) {
|
|
48
|
-
if (!EVENT_NAMES.has(event.event))
|
|
49
|
-
throw eventError("ROLE_CONTROL_EVENT_INVALID");
|
|
50
|
-
if (typeof event.event_id !== "string" || !event.event_id) {
|
|
51
|
-
throw eventError("ROLE_CONTROL_EVENT_REQUIRED");
|
|
52
|
-
}
|
|
53
|
-
if (typeof event.module !== "string" || !event.module) {
|
|
54
|
-
throw eventError("ROLE_CONTROL_EVENT_REQUIRED");
|
|
55
|
-
}
|
|
56
|
-
if (!Number.isFinite(event.dur_ms) || event.dur_ms < 0) {
|
|
57
|
-
throw eventError("ROLE_CONTROL_EVENT_REQUIRED");
|
|
58
|
-
}
|
|
59
|
-
if (!CONTROL_ACTIONS.has(event.control_action)) {
|
|
60
|
-
throw eventError("ROLE_CONTROL_EVENT_REQUIRED");
|
|
61
|
-
}
|
|
62
|
-
if (typeof event.reason_code !== "string" || !event.reason_code) {
|
|
63
|
-
throw eventError("ROLE_CONTROL_EVENT_REQUIRED");
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/**
|
|
68
|
-
* Build an allowlisted event object. Unknown domain fields are intentionally
|
|
69
|
-
* discarded before the event reaches a logger.
|
|
70
|
-
*/
|
|
71
|
-
export function buildRoleControlEvent(
|
|
72
|
-
input = {},
|
|
73
|
-
{ eventId = defaultEventId } = {},
|
|
74
|
-
) {
|
|
75
|
-
const event = {
|
|
76
|
-
schema_version: ROLE_CONTROL_EVENT_SCHEMA_VERSION,
|
|
77
|
-
event: input.event,
|
|
78
|
-
event_id: input.event_id ?? eventId(),
|
|
79
|
-
module: input.module,
|
|
80
|
-
dur_ms: input.dur_ms,
|
|
81
|
-
control_action: input.control_action,
|
|
82
|
-
reason_code: input.reason_code,
|
|
83
|
-
};
|
|
84
|
-
for (const field of OPTIONAL_FIELDS) {
|
|
85
|
-
if (input[field] !== undefined) event[field] = input[field];
|
|
86
|
-
}
|
|
87
|
-
validateRequired(event);
|
|
88
|
-
return event;
|
|
89
|
-
}
|
|
90
|
-
|
|
91
|
-
export function buildCapabilityDegradedEvent(input = {}, options = {}) {
|
|
92
|
-
return buildRoleControlEvent(
|
|
93
|
-
{ ...input, event: "capability.degraded" },
|
|
94
|
-
options,
|
|
95
|
-
);
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
export function buildDriftDetectedEvent(input = {}, options = {}) {
|
|
99
|
-
return buildRoleControlEvent({ ...input, event: "drift.detected" }, options);
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
/**
|
|
103
|
-
* Pino-style loggers receive event as an object field and as the message.
|
|
104
|
-
*/
|
|
105
|
-
export function emitRoleControlEvent(log, event, level = "warn") {
|
|
106
|
-
if (!log || typeof log[level] !== "function") {
|
|
107
|
-
throw eventError("ROLE_CONTROL_LOGGER_INVALID");
|
|
108
|
-
}
|
|
109
|
-
const sanitized = buildRoleControlEvent(event, {
|
|
110
|
-
eventId: () => event?.event_id,
|
|
111
|
-
});
|
|
112
|
-
log[level](sanitized, sanitized.event);
|
|
113
|
-
}
|