@triflux/core 10.0.0-alpha.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/hooks/agent-route-guard.mjs +109 -0
- package/hooks/cross-review-tracker.mjs +122 -0
- package/hooks/error-context.mjs +148 -0
- package/hooks/hook-manager.mjs +352 -0
- package/hooks/hook-orchestrator.mjs +312 -0
- package/hooks/hook-registry.json +213 -0
- package/hooks/hooks.json +89 -0
- package/hooks/keyword-rules.json +581 -0
- package/hooks/lib/resolve-root.mjs +59 -0
- package/hooks/mcp-config-watcher.mjs +85 -0
- package/hooks/pipeline-stop.mjs +76 -0
- package/hooks/safety-guard.mjs +106 -0
- package/hooks/subagent-verifier.mjs +80 -0
- package/hub/assign-callbacks.mjs +133 -0
- package/hub/bridge.mjs +799 -0
- package/hub/cli-adapter-base.mjs +192 -0
- package/hub/codex-adapter.mjs +190 -0
- package/hub/codex-compat.mjs +78 -0
- package/hub/codex-preflight.mjs +147 -0
- package/hub/delegator/contracts.mjs +37 -0
- package/hub/delegator/index.mjs +14 -0
- package/hub/delegator/schema/delegator-tools.schema.json +250 -0
- package/hub/delegator/service.mjs +307 -0
- package/hub/delegator/tool-definitions.mjs +35 -0
- package/hub/fullcycle.mjs +96 -0
- package/hub/gemini-adapter.mjs +179 -0
- package/hub/hitl.mjs +143 -0
- package/hub/intent.mjs +193 -0
- package/hub/lib/process-utils.mjs +361 -0
- package/hub/middleware/request-logger.mjs +81 -0
- package/hub/paths.mjs +30 -0
- package/hub/pipeline/gates/confidence.mjs +56 -0
- package/hub/pipeline/gates/consensus.mjs +94 -0
- package/hub/pipeline/gates/index.mjs +5 -0
- package/hub/pipeline/gates/selfcheck.mjs +82 -0
- package/hub/pipeline/index.mjs +318 -0
- package/hub/pipeline/state.mjs +191 -0
- package/hub/pipeline/transitions.mjs +124 -0
- package/hub/platform.mjs +225 -0
- package/hub/quality/deslop.mjs +253 -0
- package/hub/reflexion.mjs +372 -0
- package/hub/research.mjs +146 -0
- package/hub/router.mjs +791 -0
- package/hub/routing/complexity.mjs +166 -0
- package/hub/routing/index.mjs +117 -0
- package/hub/routing/q-learning.mjs +336 -0
- package/hub/session-fingerprint.mjs +352 -0
- package/hub/state.mjs +245 -0
- package/hub/team-bridge.mjs +25 -0
- package/hub/token-mode.mjs +224 -0
- package/hub/workers/worker-utils.mjs +104 -0
- package/hud/colors.mjs +88 -0
- package/hud/constants.mjs +81 -0
- package/hud/hud-qos-status.mjs +206 -0
- package/hud/providers/claude.mjs +309 -0
- package/hud/providers/codex.mjs +151 -0
- package/hud/providers/gemini.mjs +320 -0
- package/hud/renderers.mjs +424 -0
- package/hud/terminal.mjs +140 -0
- package/hud/utils.mjs +287 -0
- package/package.json +31 -0
- package/scripts/lib/claudemd-manager.mjs +325 -0
- package/scripts/lib/context.mjs +67 -0
- package/scripts/lib/cross-review-utils.mjs +51 -0
- package/scripts/lib/env-probe.mjs +241 -0
- package/scripts/lib/gemini-profiles.mjs +85 -0
- package/scripts/lib/hook-utils.mjs +14 -0
- package/scripts/lib/keyword-rules.mjs +166 -0
- package/scripts/lib/logger.mjs +105 -0
- package/scripts/lib/mcp-filter.mjs +739 -0
- package/scripts/lib/mcp-guard-engine.mjs +940 -0
- package/scripts/lib/mcp-manifest.mjs +79 -0
- package/scripts/lib/mcp-server-catalog.mjs +118 -0
- package/scripts/lib/psmux-info.mjs +119 -0
- package/scripts/lib/remote-spawn-transfer.mjs +196 -0
package/hud/utils.mjs
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
// ============================================================================
|
|
2
|
+
// 유틸리티 함수
|
|
3
|
+
// ============================================================================
|
|
4
|
+
import { existsSync, readFileSync, writeFileSync, mkdirSync } from "node:fs";
|
|
5
|
+
import { dirname } from "node:path";
|
|
6
|
+
import { createHash } from "node:crypto";
|
|
7
|
+
import {
|
|
8
|
+
PERCENT_CELL_WIDTH, TIME_CELL_INNER_WIDTH, SV_CELL_WIDTH,
|
|
9
|
+
} from "./constants.mjs";
|
|
10
|
+
|
|
11
|
+
export async function readStdinJson() {
|
|
12
|
+
if (process.stdin.isTTY) return {};
|
|
13
|
+
return new Promise((resolve) => {
|
|
14
|
+
const timeout = setTimeout(() => {
|
|
15
|
+
process.stdin.destroy();
|
|
16
|
+
resolve({});
|
|
17
|
+
}, 200);
|
|
18
|
+
const chunks = [];
|
|
19
|
+
process.stdin.on("data", (chunk) => chunks.push(chunk));
|
|
20
|
+
process.stdin.on("end", () => {
|
|
21
|
+
clearTimeout(timeout);
|
|
22
|
+
const raw = chunks.join("").trim();
|
|
23
|
+
if (!raw) { resolve({}); return; }
|
|
24
|
+
try { resolve(JSON.parse(raw)); } catch { resolve({}); }
|
|
25
|
+
});
|
|
26
|
+
process.stdin.on("error", () => {
|
|
27
|
+
clearTimeout(timeout);
|
|
28
|
+
resolve({});
|
|
29
|
+
});
|
|
30
|
+
process.stdin.resume();
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function readJson(filePath, fallback) {
|
|
35
|
+
if (!existsSync(filePath)) return fallback;
|
|
36
|
+
try { return JSON.parse(readFileSync(filePath, "utf-8")); } catch { return fallback; }
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function writeJsonSafe(filePath, data) {
|
|
40
|
+
try {
|
|
41
|
+
mkdirSync(dirname(filePath), { recursive: true });
|
|
42
|
+
writeFileSync(filePath, JSON.stringify(data), { mode: 0o600 });
|
|
43
|
+
} catch { /* 쓰기 실패 무시 */ }
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// .omc/ → .claude/cache/ 마이그레이션: 새 경로 우선, 없으면 레거시 읽고 복사
|
|
47
|
+
export function readJsonMigrate(newPath, legacyPath, fallback) {
|
|
48
|
+
const data = readJson(newPath, null);
|
|
49
|
+
if (data != null) return data;
|
|
50
|
+
const legacy = readJson(legacyPath, null);
|
|
51
|
+
if (legacy != null) { writeJsonSafe(newPath, legacy); return legacy; }
|
|
52
|
+
return fallback;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function stripAnsi(text) {
|
|
56
|
+
return String(text).replace(/\x1b\[[0-9;]*m/g, "");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function getVisibleLength(text) {
|
|
60
|
+
return stripAnsi(text).length;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function padAnsi(text, width, align = "right") {
|
|
64
|
+
const len = getVisibleLength(text);
|
|
65
|
+
if (len >= width) return text;
|
|
66
|
+
const padding = " ".repeat(width - len);
|
|
67
|
+
return align === "left" ? padding + text : text + padding;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function padAnsiRight(text, width) {
|
|
71
|
+
return padAnsi(text, width, "right");
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function padAnsiLeft(text, width) {
|
|
75
|
+
return padAnsi(text, width, "left");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function fitText(text, width) {
|
|
79
|
+
const t = String(text || "");
|
|
80
|
+
if (t.length <= width) return t;
|
|
81
|
+
if (width <= 1) return "…";
|
|
82
|
+
return `${t.slice(0, width - 1)}…`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function makeHash(text) {
|
|
86
|
+
return createHash("sha256").update(String(text || ""), "utf8").digest("hex").slice(0, 16);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function clampPercent(value) {
|
|
90
|
+
const numeric = Number(value);
|
|
91
|
+
if (!Number.isFinite(numeric)) return 0;
|
|
92
|
+
return Math.max(0, Math.min(100, Math.round(numeric)));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function formatPercentCell(value) {
|
|
96
|
+
return `${clampPercent(value)}%`.padStart(PERCENT_CELL_WIDTH, " ");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function formatPlaceholderPercentCell() {
|
|
100
|
+
return "--%".padStart(PERCENT_CELL_WIDTH, " ");
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export function normalizeTimeToken(value) {
|
|
104
|
+
const text = String(value || "n/a");
|
|
105
|
+
const hourMinute = text.match(/^(\d+)h(\d+)m$/);
|
|
106
|
+
if (hourMinute) {
|
|
107
|
+
return `${Number(hourMinute[1])}h${String(Number(hourMinute[2])).padStart(2, "0")}m`;
|
|
108
|
+
}
|
|
109
|
+
const dayHour = text.match(/^(\d+)d(\d+)h$/);
|
|
110
|
+
if (dayHour) {
|
|
111
|
+
return `${String(Number(dayHour[1])).padStart(2, "0")}d${String(Number(dayHour[2])).padStart(2, "0")}h`;
|
|
112
|
+
}
|
|
113
|
+
return text;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function formatTimeCell(value) {
|
|
117
|
+
const text = normalizeTimeToken(value);
|
|
118
|
+
// 시간값(숫자 포함)은 0패딩, 비시간값(n/a 등)은 공백패딩
|
|
119
|
+
const padChar = /\d/.test(text) ? "0" : " ";
|
|
120
|
+
return `(${text.padStart(TIME_CELL_INNER_WIDTH, padChar)})`;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// 주간(d/h) 전용 — 최대 7d00h(5자)이므로 공백 불필요
|
|
124
|
+
export function formatTimeCellDH(value) {
|
|
125
|
+
const text = normalizeTimeToken(value);
|
|
126
|
+
return `(${text})`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function getCliArgValue(flag) {
|
|
130
|
+
const idx = process.argv.indexOf(flag);
|
|
131
|
+
if (idx < 0) return null;
|
|
132
|
+
return process.argv[idx + 1] || null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export function formatDuration(ms) {
|
|
136
|
+
if (!Number.isFinite(ms) || ms <= 0) return "n/a";
|
|
137
|
+
const totalMinutes = Math.floor(ms / 60000);
|
|
138
|
+
const days = Math.floor(totalMinutes / (60 * 24));
|
|
139
|
+
const hours = Math.floor((totalMinutes % (60 * 24)) / 60);
|
|
140
|
+
const minutes = totalMinutes % 60;
|
|
141
|
+
if (days > 0) return hours > 0 ? `${days}d${hours}h` : `${days}d`;
|
|
142
|
+
if (hours > 0) return minutes > 0 ? `${hours}h${minutes}m` : `${hours}h`;
|
|
143
|
+
return `${minutes}m`;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function formatTokenCount(n) {
|
|
147
|
+
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
|
148
|
+
if (n >= 1_000) return `${Math.round(n / 1_000)}K`;
|
|
149
|
+
return String(n);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export function getContextPercent(stdin) {
|
|
153
|
+
const nativePercent = stdin?.context_window?.used_percentage;
|
|
154
|
+
if (typeof nativePercent === "number" && Number.isFinite(nativePercent)) return clampPercent(nativePercent);
|
|
155
|
+
const usage = stdin?.context_window?.current_usage || {};
|
|
156
|
+
const totalTokens = Number(usage.input_tokens || 0)
|
|
157
|
+
+ Number(usage.cache_creation_input_tokens || 0)
|
|
158
|
+
+ Number(usage.cache_read_input_tokens || 0);
|
|
159
|
+
const capacity = Number(stdin?.context_window?.context_window_size || 0);
|
|
160
|
+
if (!capacity || capacity <= 0) return 0;
|
|
161
|
+
return clampPercent((totalTokens / capacity) * 100);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// 과거 리셋 시간 → 다음 주기로 순환하여 미래 시점 반환
|
|
165
|
+
export function advanceToNextCycle(epochMs, cycleMs) {
|
|
166
|
+
const now = Date.now();
|
|
167
|
+
if (epochMs >= now || !cycleMs) return epochMs;
|
|
168
|
+
const elapsed = now - epochMs;
|
|
169
|
+
return epochMs + Math.ceil(elapsed / cycleMs) * cycleMs;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function parseResetDate(isoOrUnix) {
|
|
173
|
+
if (!isoOrUnix) return null;
|
|
174
|
+
const date = typeof isoOrUnix === "string"
|
|
175
|
+
? new Date(isoOrUnix)
|
|
176
|
+
: new Date(isoOrUnix * 1000);
|
|
177
|
+
return Number.isNaN(date.getTime()) ? null : date;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function getResetTargetMs(isoOrUnix, cycleMs = 0) {
|
|
181
|
+
const date = parseResetDate(isoOrUnix);
|
|
182
|
+
if (!date) return null;
|
|
183
|
+
return advanceToNextCycle(date.getTime(), cycleMs);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function getRemainingResetMs(isoOrUnix, cycleMs = 0) {
|
|
187
|
+
const targetMs = getResetTargetMs(isoOrUnix, cycleMs);
|
|
188
|
+
if (targetMs == null) return null;
|
|
189
|
+
return targetMs - Date.now();
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function formatResetRemaining(isoOrUnix, cycleMs = 0) {
|
|
193
|
+
const diffMs = getRemainingResetMs(isoOrUnix, cycleMs);
|
|
194
|
+
if (diffMs == null || diffMs <= 0) return "";
|
|
195
|
+
const totalMinutes = Math.floor(diffMs / 60000);
|
|
196
|
+
const totalHours = Math.floor(totalMinutes / 60);
|
|
197
|
+
const minutes = totalMinutes % 60;
|
|
198
|
+
return `${totalHours}h${String(minutes).padStart(2, "0")}m`;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export function isResetPast(isoOrUnix) {
|
|
202
|
+
const date = parseResetDate(isoOrUnix);
|
|
203
|
+
return date != null && date.getTime() <= Date.now();
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export function formatResetRemainingDayHour(isoOrUnix, cycleMs = 0) {
|
|
207
|
+
const diffMs = getRemainingResetMs(isoOrUnix, cycleMs);
|
|
208
|
+
if (diffMs == null || diffMs <= 0) return "";
|
|
209
|
+
const totalMinutes = Math.floor(diffMs / 60000);
|
|
210
|
+
const days = Math.floor(totalMinutes / (60 * 24));
|
|
211
|
+
const hours = Math.floor((totalMinutes % (60 * 24)) / 60);
|
|
212
|
+
return `${String(days).padStart(2, "0")}d${String(hours).padStart(2, "0")}h`;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function calcCooldownLeftSeconds(isoDatetime) {
|
|
216
|
+
if (!isoDatetime) return 0;
|
|
217
|
+
const cooldownMs = new Date(isoDatetime).getTime() - Date.now();
|
|
218
|
+
if (!Number.isFinite(cooldownMs) || cooldownMs <= 0) return 0;
|
|
219
|
+
return Math.ceil(cooldownMs / 1000);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
export function getProviderAccountId(provider, accountsConfig, accountsState) {
|
|
223
|
+
const providerState = accountsState?.providers?.[provider] || {};
|
|
224
|
+
const selectedId = providerState.last_selected_id;
|
|
225
|
+
if (selectedId) return selectedId;
|
|
226
|
+
const providerConfig = accountsConfig?.providers?.[provider] || [];
|
|
227
|
+
return providerConfig[0]?.id || `${provider}-main`;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
// JWT base64 디코딩 공통 헬퍼
|
|
231
|
+
export function decodeJwtEmail(idToken) {
|
|
232
|
+
if (!idToken) return null;
|
|
233
|
+
const parts = idToken.split(".");
|
|
234
|
+
if (parts.length < 2) return null;
|
|
235
|
+
let payload = parts[1].replace(/-/g, "+").replace(/_/g, "/");
|
|
236
|
+
while (payload.length % 4) payload += "=";
|
|
237
|
+
try {
|
|
238
|
+
const decoded = JSON.parse(Buffer.from(payload, "base64").toString("utf-8"));
|
|
239
|
+
return decoded.email || null;
|
|
240
|
+
} catch { return null; }
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// HTTPS POST (타임아웃 포함) — https 모듈은 호출자가 주입
|
|
244
|
+
export function createHttpsPost(https, timeoutMs) {
|
|
245
|
+
return function httpsPost(url, body, accessToken) {
|
|
246
|
+
return new Promise((resolve) => {
|
|
247
|
+
const urlObj = new URL(url);
|
|
248
|
+
const data = JSON.stringify(body);
|
|
249
|
+
const req = https.request({
|
|
250
|
+
hostname: urlObj.hostname,
|
|
251
|
+
path: urlObj.pathname + urlObj.search,
|
|
252
|
+
method: "POST",
|
|
253
|
+
headers: {
|
|
254
|
+
"Content-Type": "application/json",
|
|
255
|
+
"Authorization": `Bearer ${accessToken}`,
|
|
256
|
+
"Content-Length": Buffer.byteLength(data),
|
|
257
|
+
},
|
|
258
|
+
timeout: timeoutMs,
|
|
259
|
+
}, (res) => {
|
|
260
|
+
const chunks = [];
|
|
261
|
+
res.on("data", (c) => chunks.push(c));
|
|
262
|
+
res.on("end", () => {
|
|
263
|
+
try { resolve(JSON.parse(Buffer.concat(chunks).toString())); }
|
|
264
|
+
catch { resolve(null); }
|
|
265
|
+
});
|
|
266
|
+
});
|
|
267
|
+
req.on("error", () => resolve(null));
|
|
268
|
+
req.on("timeout", () => { req.destroy(); resolve(null); });
|
|
269
|
+
req.write(data);
|
|
270
|
+
req.end();
|
|
271
|
+
});
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// sv 퍼센트 포맷 (1000+ → k 표기, 5자 고정폭)
|
|
276
|
+
export function formatSvPct(value) {
|
|
277
|
+
if (value == null) return "--%".padStart(SV_CELL_WIDTH);
|
|
278
|
+
if (value >= 10000) return `${Math.round(value / 1000)}k%`.padStart(SV_CELL_WIDTH);
|
|
279
|
+
if (value >= 1000) return `${(value / 1000).toFixed(1)}k%`.padStart(SV_CELL_WIDTH);
|
|
280
|
+
return `${value}%`.padStart(SV_CELL_WIDTH);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export function formatSavings(dollars) {
|
|
284
|
+
if (dollars >= 100) return `$${Math.round(dollars)}`;
|
|
285
|
+
if (dollars >= 10) return `$${dollars.toFixed(1)}`;
|
|
286
|
+
return `$${dollars.toFixed(2)}`;
|
|
287
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@triflux/core",
|
|
3
|
+
"version": "10.0.0-alpha.1",
|
|
4
|
+
"description": "triflux core — CLI routing, pipeline, adapters. Zero native dependencies.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"main": "hub/index.mjs",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./hub/index.mjs",
|
|
9
|
+
"./hub/*": "./hub/*"
|
|
10
|
+
},
|
|
11
|
+
"engines": { "node": ">=18.0.0" },
|
|
12
|
+
"dependencies": {
|
|
13
|
+
"pino": "^10.3.1",
|
|
14
|
+
"pino-pretty": "^13.1.3",
|
|
15
|
+
"zod": "^4.0.0"
|
|
16
|
+
},
|
|
17
|
+
"files": [
|
|
18
|
+
"hub",
|
|
19
|
+
"scripts/lib",
|
|
20
|
+
"hooks",
|
|
21
|
+
"hud"
|
|
22
|
+
],
|
|
23
|
+
"keywords": ["claude-code", "cli-routing", "codex", "gemini", "triflux"],
|
|
24
|
+
"author": "tellang",
|
|
25
|
+
"license": "MIT",
|
|
26
|
+
"repository": {
|
|
27
|
+
"type": "git",
|
|
28
|
+
"url": "git+https://github.com/tellang/triflux.git",
|
|
29
|
+
"directory": "packages/core"
|
|
30
|
+
}
|
|
31
|
+
}
|
|
@@ -0,0 +1,325 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* claudemd-manager.mjs — TFX CLAUDE.md 섹션 관리
|
|
3
|
+
*
|
|
4
|
+
* OMC 패턴 차용: <!-- TFX:START --> / <!-- TFX:END --> 마커 기반.
|
|
5
|
+
* setup/update 시 마커 사이 내용을 템플릿으로 교체.
|
|
6
|
+
* doctor 시 stale 감지 + 직접 호출 충돌 경고.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { readFileSync, writeFileSync, existsSync } from "node:fs";
|
|
10
|
+
import { homedir } from "node:os";
|
|
11
|
+
import { join, dirname } from "node:path";
|
|
12
|
+
import { fileURLToPath } from "node:url";
|
|
13
|
+
|
|
14
|
+
const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url));
|
|
15
|
+
const TEMPLATE_PATH = join(SCRIPT_DIR, "..", "templates", "claudemd-tfx-section.md");
|
|
16
|
+
|
|
17
|
+
const TFX_START = "<!-- TFX:START -->";
|
|
18
|
+
const TFX_END = "<!-- TFX:END -->";
|
|
19
|
+
const TFX_VERSION_RE = /<!-- TFX:VERSION:([\d.]+) -->/;
|
|
20
|
+
|
|
21
|
+
// 직접 호출 패턴 (TFX 블록 외부에서 발견되면 충돌)
|
|
22
|
+
const DIRECT_CALL_PATTERNS = [
|
|
23
|
+
/codex\s+exec\s+--dangerously/,
|
|
24
|
+
/codex\s+--profile\s+\w+\s+exec/,
|
|
25
|
+
/gemini\s+-[yp]\s/,
|
|
26
|
+
/codex\s+exec\s+review/,
|
|
27
|
+
];
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* 현재 triflux 버전을 package.json에서 읽기
|
|
31
|
+
*/
|
|
32
|
+
export function getPackageVersion() {
|
|
33
|
+
try {
|
|
34
|
+
const pkgPath = join(SCRIPT_DIR, "..", "..", "package.json");
|
|
35
|
+
const pkg = JSON.parse(readFileSync(pkgPath, "utf8"));
|
|
36
|
+
return pkg.version || "0.0.0";
|
|
37
|
+
} catch {
|
|
38
|
+
return "0.0.0";
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* 템플릿 파일 읽기
|
|
44
|
+
*/
|
|
45
|
+
export function loadTemplate() {
|
|
46
|
+
try {
|
|
47
|
+
return readFileSync(TEMPLATE_PATH, "utf8").trim();
|
|
48
|
+
} catch (error) {
|
|
49
|
+
throw new Error(`TFX 템플릿 로드 실패: ${TEMPLATE_PATH}: ${error.message}`);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* CLAUDE.md에서 TFX 섹션 추출
|
|
55
|
+
* @returns {{ found: boolean, version: string|null, content: string|null, startIdx: number, endIdx: number }}
|
|
56
|
+
*/
|
|
57
|
+
export function readSection(filePath) {
|
|
58
|
+
if (!existsSync(filePath)) {
|
|
59
|
+
return { found: false, version: null, content: null, startIdx: -1, endIdx: -1 };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const raw = readFileSync(filePath, "utf8");
|
|
63
|
+
const startIdx = raw.indexOf(TFX_START);
|
|
64
|
+
const endIdx = raw.indexOf(TFX_END);
|
|
65
|
+
|
|
66
|
+
if (startIdx === -1 || endIdx === -1 || endIdx <= startIdx) {
|
|
67
|
+
return { found: false, version: null, content: null, startIdx: -1, endIdx: -1 };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const section = raw.slice(startIdx, endIdx + TFX_END.length);
|
|
71
|
+
const versionMatch = section.match(TFX_VERSION_RE);
|
|
72
|
+
const version = versionMatch ? versionMatch[1] : null;
|
|
73
|
+
|
|
74
|
+
return { found: true, version, content: section, startIdx, endIdx: endIdx + TFX_END.length };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* TFX 섹션 쓰기/교체
|
|
79
|
+
* - 기존 마커가 있으면 교체
|
|
80
|
+
* - 없으면 OMC:END 뒤에 삽입 (없으면 파일 끝)
|
|
81
|
+
*/
|
|
82
|
+
export function writeSection(filePath, options = {}) {
|
|
83
|
+
const version = options.version || getPackageVersion();
|
|
84
|
+
const template = options.template || loadTemplate();
|
|
85
|
+
|
|
86
|
+
const block = [
|
|
87
|
+
TFX_START,
|
|
88
|
+
`<!-- TFX:VERSION:${version} -->`,
|
|
89
|
+
template,
|
|
90
|
+
TFX_END,
|
|
91
|
+
].join("\n");
|
|
92
|
+
|
|
93
|
+
if (!existsSync(filePath)) {
|
|
94
|
+
writeFileSync(filePath, block + "\n", "utf8");
|
|
95
|
+
return { action: "created", version };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const raw = readFileSync(filePath, "utf8");
|
|
99
|
+
const existing = readSection(filePath);
|
|
100
|
+
|
|
101
|
+
if (existing.found) {
|
|
102
|
+
// 기존 TFX 블록 교체
|
|
103
|
+
const before = raw.slice(0, existing.startIdx);
|
|
104
|
+
const after = raw.slice(existing.endIdx);
|
|
105
|
+
writeFileSync(filePath, before + block + after, "utf8");
|
|
106
|
+
return { action: "updated", oldVersion: existing.version, version };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// OMC:END 뒤에 삽입
|
|
110
|
+
const omcEndIdx = raw.indexOf("<!-- OMC:END -->");
|
|
111
|
+
if (omcEndIdx !== -1) {
|
|
112
|
+
const insertAt = omcEndIdx + "<!-- OMC:END -->".length;
|
|
113
|
+
const before = raw.slice(0, insertAt);
|
|
114
|
+
const after = raw.slice(insertAt);
|
|
115
|
+
writeFileSync(filePath, before + "\n" + block + after, "utf8");
|
|
116
|
+
return { action: "inserted_after_omc", version };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// 파일 끝에 추가
|
|
120
|
+
const separator = raw.endsWith("\n") ? "" : "\n";
|
|
121
|
+
writeFileSync(filePath, raw + separator + block + "\n", "utf8");
|
|
122
|
+
return { action: "appended", version };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* TFX 섹션 제거
|
|
127
|
+
*/
|
|
128
|
+
export function removeSection(filePath) {
|
|
129
|
+
if (!existsSync(filePath)) return { action: "no_file" };
|
|
130
|
+
|
|
131
|
+
const raw = readFileSync(filePath, "utf8");
|
|
132
|
+
const existing = readSection(filePath);
|
|
133
|
+
|
|
134
|
+
if (!existing.found) return { action: "not_found" };
|
|
135
|
+
|
|
136
|
+
const before = raw.slice(0, existing.startIdx);
|
|
137
|
+
const after = raw.slice(existing.endIdx);
|
|
138
|
+
// 연속 빈줄 정리
|
|
139
|
+
const cleaned = (before + after).replace(/\n{3,}/g, "\n\n");
|
|
140
|
+
writeFileSync(filePath, cleaned, "utf8");
|
|
141
|
+
return { action: "removed", oldVersion: existing.version };
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* TFX 블록 외부에서 직접 CLI 호출 감지
|
|
146
|
+
* @returns {{ conflicts: Array<{line: number, text: string, pattern: string}> }}
|
|
147
|
+
*/
|
|
148
|
+
export function detectConflicts(filePath) {
|
|
149
|
+
if (!existsSync(filePath)) return { conflicts: [] };
|
|
150
|
+
|
|
151
|
+
const raw = readFileSync(filePath, "utf8");
|
|
152
|
+
const existing = readSection(filePath);
|
|
153
|
+
|
|
154
|
+
// TFX 블록 내부 제거 후 검사
|
|
155
|
+
let textOutside = raw;
|
|
156
|
+
if (existing.found) {
|
|
157
|
+
textOutside = raw.slice(0, existing.startIdx) + raw.slice(existing.endIdx);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
const lines = textOutside.split("\n");
|
|
161
|
+
const conflicts = [];
|
|
162
|
+
|
|
163
|
+
for (let i = 0; i < lines.length; i++) {
|
|
164
|
+
const line = lines[i];
|
|
165
|
+
// 코드블록 안은 무시
|
|
166
|
+
if (line.trim().startsWith("```")) continue;
|
|
167
|
+
// 주석/금지 문맥 무시
|
|
168
|
+
if (/금지|차단|NEVER|prohibit|block/i.test(line)) continue;
|
|
169
|
+
|
|
170
|
+
for (const pattern of DIRECT_CALL_PATTERNS) {
|
|
171
|
+
if (pattern.test(line)) {
|
|
172
|
+
conflicts.push({
|
|
173
|
+
line: i + 1,
|
|
174
|
+
text: line.trim(),
|
|
175
|
+
pattern: pattern.source,
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return { conflicts };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* Doctor 진단 — 종합 검사
|
|
186
|
+
* @returns {{ status: "ok"|"warn"|"error", issues: string[] }}
|
|
187
|
+
*/
|
|
188
|
+
export function diagnose(filePath) {
|
|
189
|
+
const issues = [];
|
|
190
|
+
|
|
191
|
+
if (!existsSync(filePath)) {
|
|
192
|
+
return { status: "error", issues: ["CLAUDE.md 파일 없음: " + filePath] };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const section = readSection(filePath);
|
|
196
|
+
if (!section.found) {
|
|
197
|
+
issues.push("TFX 섹션 없음 — `tfx setup` 또는 `/tfx-setup`으로 추가 필요");
|
|
198
|
+
} else {
|
|
199
|
+
const currentVersion = getPackageVersion();
|
|
200
|
+
if (section.version !== currentVersion) {
|
|
201
|
+
issues.push(`TFX 섹션 stale: ${section.version} → ${currentVersion} (업데이트 필요)`);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const { conflicts } = detectConflicts(filePath);
|
|
206
|
+
for (const c of conflicts) {
|
|
207
|
+
issues.push(`L${c.line}: 직접 CLI 호출 감지 — "${c.text.slice(0, 60)}..."`);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (issues.length === 0) return { status: "ok", issues: [] };
|
|
211
|
+
if (issues.some((i) => i.includes("stale") || i.includes("없음"))) return { status: "error", issues };
|
|
212
|
+
return { status: "warn", issues };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// ── 기존 유저 마이그레이션 ──
|
|
216
|
+
|
|
217
|
+
// 레거시 <user_cli_routing> 마커 없는 섹션 감지
|
|
218
|
+
const LEGACY_PATTERNS = [
|
|
219
|
+
/<user_cli_routing>/,
|
|
220
|
+
/Codex Pro 무료 기간/,
|
|
221
|
+
/codex exec --dangerously-bypass.*skip-git-repo-check/,
|
|
222
|
+
/OMC 에이전트 → CLI 매핑/,
|
|
223
|
+
/Spark 가드레일/,
|
|
224
|
+
];
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* 레거시 CLI 라우팅 섹션 감지
|
|
228
|
+
* @returns {{ found: boolean, startIdx: number, endIdx: number, markers: string[] }}
|
|
229
|
+
*/
|
|
230
|
+
export function detectLegacy(filePath) {
|
|
231
|
+
if (!existsSync(filePath)) return { found: false, startIdx: -1, endIdx: -1, markers: [] };
|
|
232
|
+
|
|
233
|
+
const raw = readFileSync(filePath, "utf8");
|
|
234
|
+
const markers = [];
|
|
235
|
+
|
|
236
|
+
for (const p of LEGACY_PATTERNS) {
|
|
237
|
+
if (p.test(raw)) markers.push(p.source);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (markers.length === 0) return { found: false, startIdx: -1, endIdx: -1, markers: [] };
|
|
241
|
+
|
|
242
|
+
// <user_cli_routing> ... </user_cli_routing> 블록 범위 찾기
|
|
243
|
+
const startTag = raw.indexOf("<user_cli_routing>");
|
|
244
|
+
const endTag = raw.indexOf("</user_cli_routing>");
|
|
245
|
+
|
|
246
|
+
if (startTag !== -1 && endTag !== -1) {
|
|
247
|
+
return { found: true, startIdx: startTag, endIdx: endTag + "</user_cli_routing>".length, markers };
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return { found: true, startIdx: -1, endIdx: -1, markers };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* 레거시 섹션을 TFX 관리 블록으로 마이그레이션
|
|
255
|
+
* 1. 레거시 <user_cli_routing> 제거
|
|
256
|
+
* 2. TFX 마커 블록 삽입
|
|
257
|
+
* @returns {{ action: string, removed: string[], version: string }}
|
|
258
|
+
*/
|
|
259
|
+
export function migrate(filePath, options = {}) {
|
|
260
|
+
const legacy = detectLegacy(filePath);
|
|
261
|
+
const existing = readSection(filePath);
|
|
262
|
+
|
|
263
|
+
// 이미 TFX 마커가 있고 레거시가 없으면 스킵
|
|
264
|
+
if (existing.found && !legacy.found) {
|
|
265
|
+
return { action: "already_managed", removed: [], version: existing.version };
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const raw = readFileSync(filePath, "utf8");
|
|
269
|
+
let cleaned = raw;
|
|
270
|
+
|
|
271
|
+
const removed = [];
|
|
272
|
+
|
|
273
|
+
// 1. 레거시 <user_cli_routing> 블록 제거
|
|
274
|
+
if (legacy.found && legacy.startIdx !== -1) {
|
|
275
|
+
// <!-- USER OVERRIDES --> 코멘트도 같이 제거
|
|
276
|
+
let removeStart = legacy.startIdx;
|
|
277
|
+
const userOverrideComment = "<!-- USER OVERRIDES";
|
|
278
|
+
const commentIdx = cleaned.lastIndexOf(userOverrideComment, removeStart);
|
|
279
|
+
if (commentIdx !== -1 && removeStart - commentIdx < 200) {
|
|
280
|
+
removeStart = commentIdx;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
const before = cleaned.slice(0, removeStart);
|
|
284
|
+
const after = cleaned.slice(legacy.endIdx);
|
|
285
|
+
cleaned = (before + after).replace(/\n{3,}/g, "\n\n");
|
|
286
|
+
removed.push("<user_cli_routing> block");
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// 2. TFX 블록이 있으면 제거 (교체 위해)
|
|
290
|
+
if (existing.found) {
|
|
291
|
+
const s = readSection(filePath);
|
|
292
|
+
if (s.found) {
|
|
293
|
+
cleaned = cleaned.slice(0, cleaned.indexOf(TFX_START)) +
|
|
294
|
+
cleaned.slice(cleaned.indexOf(TFX_END) + TFX_END.length);
|
|
295
|
+
cleaned = cleaned.replace(/\n{3,}/g, "\n\n");
|
|
296
|
+
removed.push("old TFX block");
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// 3. 정리된 파일 저장
|
|
301
|
+
writeFileSync(filePath, cleaned, "utf8");
|
|
302
|
+
|
|
303
|
+
// 4. 새 TFX 블록 삽입
|
|
304
|
+
const result = writeSection(filePath, options);
|
|
305
|
+
|
|
306
|
+
return { action: "migrated", removed, version: result.version };
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/**
|
|
310
|
+
* 모든 CLAUDE.md 파일 스캔 (전역 + 프로젝트)
|
|
311
|
+
* @returns {string[]} 경로 목록
|
|
312
|
+
*/
|
|
313
|
+
export function findAllClaudeMdPaths() {
|
|
314
|
+
const paths = [];
|
|
315
|
+
|
|
316
|
+
// 전역
|
|
317
|
+
const globalPath = join(homedir(), ".claude", "CLAUDE.md");
|
|
318
|
+
if (existsSync(globalPath)) paths.push(globalPath);
|
|
319
|
+
|
|
320
|
+
// 현재 프로젝트
|
|
321
|
+
const cwdPath = join(process.cwd(), "CLAUDE.md");
|
|
322
|
+
if (existsSync(cwdPath)) paths.push(cwdPath);
|
|
323
|
+
|
|
324
|
+
return paths;
|
|
325
|
+
}
|