@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
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// hooks/hook-manager.mjs — 훅 우선순위 매니저
|
|
3
|
+
//
|
|
4
|
+
// 사용법:
|
|
5
|
+
// node hook-manager.mjs scan — 현재 settings.json 훅 스캔 → JSON 리포트
|
|
6
|
+
// node hook-manager.mjs diff — 오케스트레이터 적용 시 변경점 미리보기
|
|
7
|
+
// node hook-manager.mjs apply — settings.json에 오케스트레이터 적용
|
|
8
|
+
// node hook-manager.mjs restore — 백업에서 원래 settings.json 훅 복원
|
|
9
|
+
// node hook-manager.mjs set-priority <hookId> <priority> — 특정 훅 우선순위 변경
|
|
10
|
+
// node hook-manager.mjs toggle <hookId> — 특정 훅 활성/비활성 토글
|
|
11
|
+
// node hook-manager.mjs status — 오케스트레이터 적용 상태 확인
|
|
12
|
+
//
|
|
13
|
+
// Claude 대화에서 AskUserQuestion으로 UI를 제공하며 내부적으로 이 명령들을 호출합니다.
|
|
14
|
+
|
|
15
|
+
import { readFileSync, writeFileSync, existsSync } from "node:fs";
|
|
16
|
+
import { join, dirname } from "node:path";
|
|
17
|
+
import { fileURLToPath } from "node:url";
|
|
18
|
+
import { PLUGIN_ROOT } from "./lib/resolve-root.mjs";
|
|
19
|
+
|
|
20
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
21
|
+
const HOME = process.env.HOME || process.env.USERPROFILE || "";
|
|
22
|
+
const SETTINGS_PATH = join(HOME, ".claude", "settings.json");
|
|
23
|
+
const BACKUP_PATH = join(HOME, ".claude", "settings.hooks-backup.json");
|
|
24
|
+
const REGISTRY_PATH = join(__dirname, "hook-registry.json");
|
|
25
|
+
|
|
26
|
+
// ── 유틸리티 ────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
function loadJSON(path) {
|
|
29
|
+
if (!existsSync(path)) return null;
|
|
30
|
+
try {
|
|
31
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
32
|
+
} catch {
|
|
33
|
+
return null;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function saveJSON(path, data) {
|
|
38
|
+
writeFileSync(path, JSON.stringify(data, null, 2) + "\n", "utf8");
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function getNodeExe() {
|
|
42
|
+
return process.execPath || "node";
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// ── scan: 현재 settings.json 훅 분석 ───────────────────────
|
|
46
|
+
|
|
47
|
+
function scan() {
|
|
48
|
+
const settings = loadJSON(SETTINGS_PATH);
|
|
49
|
+
if (!settings?.hooks) {
|
|
50
|
+
return { status: "no_hooks", message: "settings.json에 훅이 없습니다.", events: {} };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const registry = loadJSON(REGISTRY_PATH);
|
|
54
|
+
const report = { status: "ok", events: {}, unregistered: [] };
|
|
55
|
+
|
|
56
|
+
for (const [event, matchers] of Object.entries(settings.hooks)) {
|
|
57
|
+
report.events[event] = { hooks: [], count: 0 };
|
|
58
|
+
|
|
59
|
+
for (const matcher of matchers) {
|
|
60
|
+
for (const hook of matcher.hooks || []) {
|
|
61
|
+
const cmd = hook.command || "";
|
|
62
|
+
const hookInfo = {
|
|
63
|
+
event,
|
|
64
|
+
matcher: matcher.matcher || "*",
|
|
65
|
+
command: cmd,
|
|
66
|
+
timeout: hook.timeout,
|
|
67
|
+
type: hook.type || "command",
|
|
68
|
+
source: identifySource(cmd),
|
|
69
|
+
registryMatch: null,
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
// 레지스트리에서 매칭 찾기
|
|
73
|
+
if (registry?.events?.[event]) {
|
|
74
|
+
const match = registry.events[event].find(
|
|
75
|
+
(r) => normalizeCmd(resolveVars(r.command)) === normalizeCmd(cmd)
|
|
76
|
+
);
|
|
77
|
+
if (match) {
|
|
78
|
+
hookInfo.registryMatch = { id: match.id, priority: match.priority };
|
|
79
|
+
} else {
|
|
80
|
+
report.unregistered.push(hookInfo);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
report.events[event].hooks.push(hookInfo);
|
|
85
|
+
report.events[event].count++;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return report;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function identifySource(cmd) {
|
|
94
|
+
if (/triflux/i.test(cmd) || /\$\{?CLAUDE_PLUGIN_ROOT\}?/i.test(cmd)) return "triflux";
|
|
95
|
+
if (/oh-my-claudecode|omc/i.test(cmd)) return "omc";
|
|
96
|
+
if (/session-vault/i.test(cmd)) return "session-vault";
|
|
97
|
+
if (/compact-helper/i.test(cmd)) return "compact-helper";
|
|
98
|
+
if (/headless-guard|tfx-gate/i.test(cmd)) return "omc";
|
|
99
|
+
if (/mcp-cleanup/i.test(cmd)) return "system";
|
|
100
|
+
return "unknown";
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function normalizeCmd(cmd) {
|
|
104
|
+
return cmd.replace(/["']/g, "").replace(/\\/g, "/").replace(/\s+/g, " ").trim().toLowerCase();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function resolveVars(cmd) {
|
|
108
|
+
return cmd
|
|
109
|
+
.replace(/\$\{PLUGIN_ROOT\}/g, PLUGIN_ROOT)
|
|
110
|
+
.replace(/\$\{CLAUDE_PLUGIN_ROOT\}/g, PLUGIN_ROOT)
|
|
111
|
+
.replace(/\$\{HOME\}/g, HOME)
|
|
112
|
+
.replace(/\$HOME\b/g, HOME);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// ── diff: 적용 시 변경점 미리보기 ───────────────────────────
|
|
116
|
+
|
|
117
|
+
function diff() {
|
|
118
|
+
const settings = loadJSON(SETTINGS_PATH);
|
|
119
|
+
if (!settings?.hooks) return { status: "no_hooks", changes: [] };
|
|
120
|
+
|
|
121
|
+
const registry = loadJSON(REGISTRY_PATH);
|
|
122
|
+
if (!registry) return { status: "no_registry", changes: [] };
|
|
123
|
+
|
|
124
|
+
const changes = [];
|
|
125
|
+
const currentEvents = Object.keys(settings.hooks);
|
|
126
|
+
const registryEvents = Object.keys(registry.events);
|
|
127
|
+
const allEvents = [...new Set([...currentEvents, ...registryEvents])];
|
|
128
|
+
|
|
129
|
+
for (const event of allEvents) {
|
|
130
|
+
const currentHooks = settings.hooks[event] || [];
|
|
131
|
+
const registryHooks = registry.events[event] || [];
|
|
132
|
+
|
|
133
|
+
const currentCount = currentHooks.reduce((n, m) => n + (m.hooks?.length || 0), 0);
|
|
134
|
+
const registryCount = registryHooks.filter((h) => h.enabled !== false).length;
|
|
135
|
+
|
|
136
|
+
if (currentCount === 1 && isOrchestrator(currentHooks)) {
|
|
137
|
+
changes.push({ event, action: "already_orchestrated", currentCount, registryCount });
|
|
138
|
+
} else if (currentCount > 0 || registryCount > 0) {
|
|
139
|
+
changes.push({
|
|
140
|
+
event,
|
|
141
|
+
action: "will_replace",
|
|
142
|
+
currentCount,
|
|
143
|
+
registryCount,
|
|
144
|
+
detail: `${currentCount}개 개별 훅 → 1개 오케스트레이터 (내부 ${registryCount}개 순차 실행)`,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return { status: "ok", changes };
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function isOrchestrator(matchers) {
|
|
153
|
+
if (!matchers || matchers.length !== 1) return false;
|
|
154
|
+
const hooks = matchers[0]?.hooks || [];
|
|
155
|
+
return hooks.length === 1 && (hooks[0]?.command || "").includes("hook-orchestrator");
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ── apply: 오케스트레이터 적용 ──────────────────────────────
|
|
159
|
+
|
|
160
|
+
function apply() {
|
|
161
|
+
const settings = loadJSON(SETTINGS_PATH);
|
|
162
|
+
if (!settings) return { status: "error", message: "settings.json을 찾을 수 없습니다." };
|
|
163
|
+
|
|
164
|
+
const registry = loadJSON(REGISTRY_PATH);
|
|
165
|
+
if (!registry) return { status: "error", message: "hook-registry.json을 찾을 수 없습니다." };
|
|
166
|
+
|
|
167
|
+
// 백업
|
|
168
|
+
if (settings.hooks && !existsSync(BACKUP_PATH)) {
|
|
169
|
+
saveJSON(BACKUP_PATH, { hooks: settings.hooks, backedUpAt: new Date().toISOString() });
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// 오케스트레이터 명령 생성
|
|
173
|
+
const nodeExe = getNodeExe();
|
|
174
|
+
const orchestratorPath = join(PLUGIN_ROOT, "hooks", "hook-orchestrator.mjs");
|
|
175
|
+
const orchestratorCmd = `"${nodeExe}" "${orchestratorPath}"`;
|
|
176
|
+
|
|
177
|
+
// 모든 이벤트를 하나의 오케스트레이터로 통합
|
|
178
|
+
const newHooks = {};
|
|
179
|
+
const registryEvents = Object.keys(registry.events);
|
|
180
|
+
|
|
181
|
+
// 레지스트리에 없는 기존 이벤트도 보존
|
|
182
|
+
const allEvents = [
|
|
183
|
+
...new Set([...registryEvents, ...Object.keys(settings.hooks || {})]),
|
|
184
|
+
];
|
|
185
|
+
|
|
186
|
+
for (const event of allEvents) {
|
|
187
|
+
const registryEntries = registry.events[event] || [];
|
|
188
|
+
const enabledEntries = registryEntries.filter((h) => h.enabled !== false);
|
|
189
|
+
|
|
190
|
+
if (enabledEntries.length > 0) {
|
|
191
|
+
// 레지스트리에 있으면 → 오케스트레이터로 교체
|
|
192
|
+
// 가장 큰 timeout을 기준으로 오케스트레이터 timeout 설정
|
|
193
|
+
const maxTimeout = Math.max(...enabledEntries.map((h) => h.timeout || 10)) + 5;
|
|
194
|
+
|
|
195
|
+
newHooks[event] = [
|
|
196
|
+
{
|
|
197
|
+
matcher: "*",
|
|
198
|
+
hooks: [
|
|
199
|
+
{
|
|
200
|
+
type: "command",
|
|
201
|
+
command: orchestratorCmd,
|
|
202
|
+
timeout: maxTimeout,
|
|
203
|
+
},
|
|
204
|
+
],
|
|
205
|
+
},
|
|
206
|
+
];
|
|
207
|
+
} else {
|
|
208
|
+
// 레지스트리에 없으면 기존 유지
|
|
209
|
+
if (settings.hooks?.[event]) {
|
|
210
|
+
newHooks[event] = settings.hooks[event];
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
settings.hooks = newHooks;
|
|
216
|
+
saveJSON(SETTINGS_PATH, settings);
|
|
217
|
+
|
|
218
|
+
return {
|
|
219
|
+
status: "applied",
|
|
220
|
+
message: `오케스트레이터 적용 완료. ${registryEvents.length}개 이벤트가 순차 실행으로 전환됩니다.`,
|
|
221
|
+
events: registryEvents,
|
|
222
|
+
backupPath: BACKUP_PATH,
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// ── restore: 백업에서 복원 ──────────────────────────────────
|
|
227
|
+
|
|
228
|
+
function restore() {
|
|
229
|
+
if (!existsSync(BACKUP_PATH)) {
|
|
230
|
+
return { status: "no_backup", message: "백업 파일이 없습니다. apply 전에는 복원할 수 없습니다." };
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const backup = loadJSON(BACKUP_PATH);
|
|
234
|
+
if (!backup?.hooks) {
|
|
235
|
+
return { status: "error", message: "백업 파일이 손상되었습니다." };
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const settings = loadJSON(SETTINGS_PATH);
|
|
239
|
+
if (!settings) return { status: "error", message: "settings.json을 찾을 수 없습니다." };
|
|
240
|
+
|
|
241
|
+
settings.hooks = backup.hooks;
|
|
242
|
+
saveJSON(SETTINGS_PATH, settings);
|
|
243
|
+
|
|
244
|
+
return {
|
|
245
|
+
status: "restored",
|
|
246
|
+
message: `원래 훅 설정이 복원되었습니다. (백업 시점: ${backup.backedUpAt})`,
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// ── set-priority: 우선순위 변경 ─────────────────────────────
|
|
251
|
+
|
|
252
|
+
function setPriority(hookId, priority) {
|
|
253
|
+
const registry = loadJSON(REGISTRY_PATH);
|
|
254
|
+
if (!registry) return { status: "error", message: "레지스트리를 찾을 수 없습니다." };
|
|
255
|
+
|
|
256
|
+
const numPriority = parseInt(priority, 10);
|
|
257
|
+
if (isNaN(numPriority)) return { status: "error", message: "priority는 숫자여야 합니다." };
|
|
258
|
+
|
|
259
|
+
let found = false;
|
|
260
|
+
for (const hooks of Object.values(registry.events)) {
|
|
261
|
+
const hook = hooks.find((h) => h.id === hookId);
|
|
262
|
+
if (hook) {
|
|
263
|
+
hook.priority = numPriority;
|
|
264
|
+
found = true;
|
|
265
|
+
break;
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
if (!found) return { status: "not_found", message: `훅 '${hookId}'를 찾을 수 없습니다.` };
|
|
270
|
+
|
|
271
|
+
saveJSON(REGISTRY_PATH, registry);
|
|
272
|
+
return { status: "ok", message: `${hookId}의 우선순위가 ${numPriority}로 변경되었습니다.` };
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// ── toggle: 활성/비활성 토글 ────────────────────────────────
|
|
276
|
+
|
|
277
|
+
function toggle(hookId) {
|
|
278
|
+
const registry = loadJSON(REGISTRY_PATH);
|
|
279
|
+
if (!registry) return { status: "error", message: "레지스트리를 찾을 수 없습니다." };
|
|
280
|
+
|
|
281
|
+
let found = false;
|
|
282
|
+
let newState = false;
|
|
283
|
+
for (const hooks of Object.values(registry.events)) {
|
|
284
|
+
const hook = hooks.find((h) => h.id === hookId);
|
|
285
|
+
if (hook) {
|
|
286
|
+
hook.enabled = !(hook.enabled !== false);
|
|
287
|
+
newState = hook.enabled;
|
|
288
|
+
found = true;
|
|
289
|
+
break;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
if (!found) return { status: "not_found", message: `훅 '${hookId}'를 찾을 수 없습니다.` };
|
|
294
|
+
|
|
295
|
+
saveJSON(REGISTRY_PATH, registry);
|
|
296
|
+
return { status: "ok", message: `${hookId}: ${newState ? "활성화" : "비활성화"}` };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// ── status: 현재 적용 상태 ──────────────────────────────────
|
|
300
|
+
|
|
301
|
+
function status() {
|
|
302
|
+
const settings = loadJSON(SETTINGS_PATH);
|
|
303
|
+
if (!settings?.hooks) return { orchestrated: false, message: "훅 없음" };
|
|
304
|
+
|
|
305
|
+
let orchestrated = 0;
|
|
306
|
+
let individual = 0;
|
|
307
|
+
|
|
308
|
+
for (const [event, matchers] of Object.entries(settings.hooks)) {
|
|
309
|
+
if (isOrchestrator(matchers)) {
|
|
310
|
+
orchestrated++;
|
|
311
|
+
} else {
|
|
312
|
+
individual++;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
const hasBackup = existsSync(BACKUP_PATH);
|
|
317
|
+
|
|
318
|
+
return {
|
|
319
|
+
orchestrated: orchestrated > 0,
|
|
320
|
+
orchestratedEvents: orchestrated,
|
|
321
|
+
individualEvents: individual,
|
|
322
|
+
hasBackup,
|
|
323
|
+
message: orchestrated > 0
|
|
324
|
+
? `오케스트레이터 적용 중: ${orchestrated}개 이벤트 통합, ${individual}개 개별 유지`
|
|
325
|
+
: `오케스트레이터 미적용. ${individual}개 이벤트가 개별 훅으로 실행 중`,
|
|
326
|
+
};
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// ── CLI 진입점 ──────────────────────────────────────────────
|
|
330
|
+
|
|
331
|
+
const [, , command, ...args] = process.argv;
|
|
332
|
+
|
|
333
|
+
const commands = {
|
|
334
|
+
scan: () => scan(),
|
|
335
|
+
diff: () => diff(),
|
|
336
|
+
apply: () => apply(),
|
|
337
|
+
restore: () => restore(),
|
|
338
|
+
"set-priority": () => setPriority(args[0], args[1]),
|
|
339
|
+
toggle: () => toggle(args[0]),
|
|
340
|
+
status: () => status(),
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
if (!command || !commands[command]) {
|
|
344
|
+
console.log(JSON.stringify({
|
|
345
|
+
error: "사용법: node hook-manager.mjs <scan|diff|apply|restore|set-priority|toggle|status>",
|
|
346
|
+
commands: Object.keys(commands),
|
|
347
|
+
}));
|
|
348
|
+
process.exit(1);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const result = commands[command]();
|
|
352
|
+
console.log(JSON.stringify(result, null, 2));
|
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// hooks/hook-orchestrator.mjs — 범용 훅 체이닝 엔진
|
|
3
|
+
//
|
|
4
|
+
// settings.json에 이벤트당 하나만 등록. stdin JSON에서 이벤트명+툴명을 읽고
|
|
5
|
+
// hook-registry.json의 우선순위대로 훅을 순차 실행한다.
|
|
6
|
+
//
|
|
7
|
+
// 실행 규칙:
|
|
8
|
+
// - priority 낮을수록 먼저 실행 (triflux=0, omc=50, external=100)
|
|
9
|
+
// - blocking:true 훅이 exit 2 반환 → 즉시 중단, 이후 훅 건너뜀
|
|
10
|
+
// - 출력(stdout JSON)은 마지막 유효 출력으로 머지
|
|
11
|
+
// - 훅 실패(exit !0 && !2)는 무시하고 다음 훅 진행
|
|
12
|
+
//
|
|
13
|
+
// 사용법:
|
|
14
|
+
// settings.json에서:
|
|
15
|
+
// { "type": "command", "command": "node .../hook-orchestrator.mjs", "timeout": 30 }
|
|
16
|
+
//
|
|
17
|
+
// 환경변수:
|
|
18
|
+
// TRIFLUX_HOOK_REGISTRY — registry 경로 오버라이드
|
|
19
|
+
// CLAUDE_PLUGIN_ROOT — ${PLUGIN_ROOT} 치환용
|
|
20
|
+
// HOME / USERPROFILE — ${HOME} 치환용
|
|
21
|
+
|
|
22
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
23
|
+
import { join, dirname } from "node:path";
|
|
24
|
+
import { fileURLToPath } from "node:url";
|
|
25
|
+
import { execFileSync, execFile } from "node:child_process";
|
|
26
|
+
import { PLUGIN_ROOT } from "./lib/resolve-root.mjs";
|
|
27
|
+
|
|
28
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
29
|
+
const REGISTRY_PATH =
|
|
30
|
+
process.env.TRIFLUX_HOOK_REGISTRY || join(__dirname, "hook-registry.json");
|
|
31
|
+
|
|
32
|
+
// ── stdin 읽기 ──────────────────────────────────────────────
|
|
33
|
+
function readStdin() {
|
|
34
|
+
try {
|
|
35
|
+
return readFileSync(0, "utf8");
|
|
36
|
+
} catch {
|
|
37
|
+
return "";
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ── 레지스트리 로드 ─────────────────────────────────────────
|
|
42
|
+
function loadRegistry() {
|
|
43
|
+
if (!existsSync(REGISTRY_PATH)) return null;
|
|
44
|
+
try {
|
|
45
|
+
return JSON.parse(readFileSync(REGISTRY_PATH, "utf8"));
|
|
46
|
+
} catch {
|
|
47
|
+
return null;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ── 경로 변수 치환 ──────────────────────────────────────────
|
|
52
|
+
function resolveCommand(cmd) {
|
|
53
|
+
const home = process.env.HOME || process.env.USERPROFILE || "";
|
|
54
|
+
|
|
55
|
+
return cmd
|
|
56
|
+
.replace(/\$\{PLUGIN_ROOT\}/g, PLUGIN_ROOT)
|
|
57
|
+
.replace(/\$\{CLAUDE_PLUGIN_ROOT\}/g, PLUGIN_ROOT)
|
|
58
|
+
.replace(/\$\{HOME\}/g, home)
|
|
59
|
+
.replace(/\$HOME\b/g, home);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ── 매처 매칭 ───────────────────────────────────────────────
|
|
63
|
+
function matchesMatcher(hookMatcher, toolName, eventInput) {
|
|
64
|
+
if (!hookMatcher || hookMatcher === "*") return true;
|
|
65
|
+
if (!toolName) return true;
|
|
66
|
+
|
|
67
|
+
// 파이프 구분 OR 매칭 (예: "Bash|Agent")
|
|
68
|
+
const patterns = hookMatcher.split("|").map((p) => p.trim());
|
|
69
|
+
return patterns.some((p) => {
|
|
70
|
+
try {
|
|
71
|
+
return new RegExp(`^${p}$`).test(toolName);
|
|
72
|
+
} catch {
|
|
73
|
+
return p === toolName;
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ── 단일 훅 실행 ────────────────────────────────────────────
|
|
79
|
+
function executeHook(hook, stdinData) {
|
|
80
|
+
const cmd = resolveCommand(hook.command);
|
|
81
|
+
const timeout = (hook.timeout || 10) * 1000;
|
|
82
|
+
|
|
83
|
+
// command 파싱: "node script.mjs" → ["node", ["script.mjs"]]
|
|
84
|
+
// "bash script.sh" → ["bash", ["script.sh"]]
|
|
85
|
+
// 따옴표 처리 포함
|
|
86
|
+
const parts = parseCommand(cmd);
|
|
87
|
+
if (parts.length === 0) return { code: 1, stdout: "", stderr: "empty command" };
|
|
88
|
+
|
|
89
|
+
const [executable, ...args] = parts;
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
const stdout = execFileSync(executable, args, {
|
|
93
|
+
input: stdinData,
|
|
94
|
+
timeout,
|
|
95
|
+
encoding: "utf8",
|
|
96
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
97
|
+
windowsHide: true,
|
|
98
|
+
cwd: process.cwd(),
|
|
99
|
+
env: { ...process.env },
|
|
100
|
+
});
|
|
101
|
+
return { code: 0, stdout: stdout || "", stderr: "" };
|
|
102
|
+
} catch (err) {
|
|
103
|
+
const code = err.status ?? 1;
|
|
104
|
+
return {
|
|
105
|
+
code,
|
|
106
|
+
stdout: err.stdout || "",
|
|
107
|
+
stderr: err.stderr || "",
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function executeHookAsync(hook, stdinData) {
|
|
113
|
+
return new Promise((resolve) => {
|
|
114
|
+
const cmd = resolveCommand(hook.command);
|
|
115
|
+
const timeout = (hook.timeout || 10) * 1000;
|
|
116
|
+
const parts = parseCommand(cmd);
|
|
117
|
+
if (parts.length === 0) { resolve({ code: 1, stdout: "", stderr: "empty command" }); return; }
|
|
118
|
+
const [executable, ...args] = parts;
|
|
119
|
+
const child = execFile(executable, args, {
|
|
120
|
+
timeout,
|
|
121
|
+
encoding: "utf8",
|
|
122
|
+
windowsHide: true,
|
|
123
|
+
cwd: process.cwd(),
|
|
124
|
+
env: { ...process.env },
|
|
125
|
+
}, (err, stdout, stderr) => {
|
|
126
|
+
if (err) resolve({ code: err.status ?? 1, stdout: stdout || "", stderr: stderr || "" });
|
|
127
|
+
else resolve({ code: 0, stdout: stdout || "", stderr: "" });
|
|
128
|
+
});
|
|
129
|
+
if (stdinData) { child.stdin.write(stdinData); child.stdin.end(); }
|
|
130
|
+
else child.stdin.end();
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ── 명령어 파싱 (따옴표 처리) ───────────────────────────────
|
|
135
|
+
function parseCommand(cmd) {
|
|
136
|
+
const parts = [];
|
|
137
|
+
let current = "";
|
|
138
|
+
let inQuote = null;
|
|
139
|
+
|
|
140
|
+
for (let i = 0; i < cmd.length; i++) {
|
|
141
|
+
const ch = cmd[i];
|
|
142
|
+
if (inQuote) {
|
|
143
|
+
if (ch === inQuote) {
|
|
144
|
+
inQuote = null;
|
|
145
|
+
} else {
|
|
146
|
+
current += ch;
|
|
147
|
+
}
|
|
148
|
+
} else if (ch === '"' || ch === "'") {
|
|
149
|
+
inQuote = ch;
|
|
150
|
+
} else if (ch === " " || ch === "\t") {
|
|
151
|
+
if (current) {
|
|
152
|
+
parts.push(current);
|
|
153
|
+
current = "";
|
|
154
|
+
}
|
|
155
|
+
} else {
|
|
156
|
+
current += ch;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
if (current) parts.push(current);
|
|
160
|
+
return parts;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// ── JSON 출력 머지 ──────────────────────────────────────────
|
|
164
|
+
function mergeOutputs(accumulated, newOutput) {
|
|
165
|
+
if (!newOutput) return accumulated;
|
|
166
|
+
|
|
167
|
+
try {
|
|
168
|
+
const parsed = JSON.parse(newOutput);
|
|
169
|
+
if (!accumulated) return parsed;
|
|
170
|
+
|
|
171
|
+
// hookSpecificOutput 머지 — additionalContext는 누적, 나머지는 덮어쓰기
|
|
172
|
+
if (parsed.hookSpecificOutput) {
|
|
173
|
+
if (accumulated.hookSpecificOutput?.additionalContext && parsed.hookSpecificOutput.additionalContext) {
|
|
174
|
+
parsed.hookSpecificOutput.additionalContext =
|
|
175
|
+
accumulated.hookSpecificOutput.additionalContext + "\n" + parsed.hookSpecificOutput.additionalContext;
|
|
176
|
+
}
|
|
177
|
+
accumulated.hookSpecificOutput = { ...accumulated.hookSpecificOutput, ...parsed.hookSpecificOutput };
|
|
178
|
+
}
|
|
179
|
+
// systemMessage는 누적
|
|
180
|
+
if (parsed.systemMessage) {
|
|
181
|
+
accumulated.systemMessage = accumulated.systemMessage
|
|
182
|
+
? accumulated.systemMessage + "\n" + parsed.systemMessage
|
|
183
|
+
: parsed.systemMessage;
|
|
184
|
+
}
|
|
185
|
+
// additionalContext는 누적
|
|
186
|
+
if (parsed.additionalContext) {
|
|
187
|
+
accumulated.additionalContext = accumulated.additionalContext
|
|
188
|
+
? accumulated.additionalContext + "\n" + parsed.additionalContext
|
|
189
|
+
: parsed.additionalContext;
|
|
190
|
+
}
|
|
191
|
+
// decision: block이 하나라도 있으면 block
|
|
192
|
+
if (parsed.decision === "block") {
|
|
193
|
+
accumulated.decision = "block";
|
|
194
|
+
accumulated.reason = parsed.reason || accumulated.reason;
|
|
195
|
+
}
|
|
196
|
+
// continue: false가 하나라도 있으면 false
|
|
197
|
+
if (parsed.continue === false) {
|
|
198
|
+
accumulated.continue = false;
|
|
199
|
+
accumulated.stopReason = parsed.stopReason || accumulated.stopReason;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return accumulated;
|
|
203
|
+
} catch {
|
|
204
|
+
// JSON이 아니면 additionalContext로 취급
|
|
205
|
+
if (!accumulated) accumulated = {};
|
|
206
|
+
accumulated.additionalContext = accumulated.additionalContext
|
|
207
|
+
? accumulated.additionalContext + "\n" + newOutput.trim()
|
|
208
|
+
: newOutput.trim();
|
|
209
|
+
return accumulated;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// ── 메인 ────────────────────────────────────────────────────
|
|
214
|
+
async function main() {
|
|
215
|
+
const stdinRaw = readStdin();
|
|
216
|
+
const registry = loadRegistry();
|
|
217
|
+
|
|
218
|
+
if (!registry) {
|
|
219
|
+
// 레지스트리 없으면 패스스루
|
|
220
|
+
if (stdinRaw.trim()) process.stdout.write(stdinRaw);
|
|
221
|
+
process.exit(0);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// stdin에서 이벤트명, 툴명 추출
|
|
225
|
+
let eventName = "";
|
|
226
|
+
let toolName = "";
|
|
227
|
+
if (stdinRaw.trim()) {
|
|
228
|
+
try {
|
|
229
|
+
const input = JSON.parse(stdinRaw);
|
|
230
|
+
eventName = input.hook_event_name || "";
|
|
231
|
+
toolName = input.tool_name || "";
|
|
232
|
+
} catch {
|
|
233
|
+
// 파싱 실패 시 그냥 통과
|
|
234
|
+
process.exit(0);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
if (!eventName) process.exit(0);
|
|
239
|
+
|
|
240
|
+
// 이벤트에 해당하는 훅 목록
|
|
241
|
+
const hooks = registry.events[eventName];
|
|
242
|
+
if (!hooks || hooks.length === 0) process.exit(0);
|
|
243
|
+
|
|
244
|
+
// 우선순위 정렬 (낮을수록 먼저)
|
|
245
|
+
const sorted = [...hooks]
|
|
246
|
+
.filter((h) => h.enabled !== false)
|
|
247
|
+
.sort((a, b) => (a.priority ?? 999) - (b.priority ?? 999));
|
|
248
|
+
|
|
249
|
+
// 매처 필터링
|
|
250
|
+
const matched = sorted.filter((h) => matchesMatcher(h.matcher, toolName));
|
|
251
|
+
|
|
252
|
+
// 같은 priority 그룹별 병렬 실행
|
|
253
|
+
const groups = [];
|
|
254
|
+
for (const hook of matched) {
|
|
255
|
+
const p = hook.priority ?? 999;
|
|
256
|
+
if (groups.length === 0 || groups[groups.length - 1].priority !== p) {
|
|
257
|
+
groups.push({ priority: p, hooks: [hook] });
|
|
258
|
+
} else {
|
|
259
|
+
groups[groups.length - 1].hooks.push(hook);
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
let mergedOutput = null;
|
|
264
|
+
let blocked = false;
|
|
265
|
+
|
|
266
|
+
for (const group of groups) {
|
|
267
|
+
if (blocked) break;
|
|
268
|
+
|
|
269
|
+
if (group.hooks.length === 1) {
|
|
270
|
+
// 단일 훅 — 기존 동기 실행
|
|
271
|
+
const result = executeHook(group.hooks[0], stdinRaw);
|
|
272
|
+
if (result.code === 2) {
|
|
273
|
+
if (result.stderr) process.stderr.write(result.stderr);
|
|
274
|
+
blocked = true;
|
|
275
|
+
break;
|
|
276
|
+
}
|
|
277
|
+
if (result.code === 0 && result.stdout.trim()) {
|
|
278
|
+
mergedOutput = mergeOutputs(mergedOutput, result.stdout.trim());
|
|
279
|
+
}
|
|
280
|
+
} else {
|
|
281
|
+
// 같은 priority 다중 훅 — 비동기 병렬 실행
|
|
282
|
+
const results = await Promise.all(group.hooks.map((h) => executeHookAsync(h, stdinRaw)));
|
|
283
|
+
for (const result of results) {
|
|
284
|
+
if (result.code === 2) {
|
|
285
|
+
if (result.stderr) process.stderr.write(result.stderr);
|
|
286
|
+
blocked = true;
|
|
287
|
+
break;
|
|
288
|
+
}
|
|
289
|
+
if (result.code === 0 && result.stdout.trim()) {
|
|
290
|
+
mergedOutput = mergeOutputs(mergedOutput, result.stdout.trim());
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// 결과 출력
|
|
297
|
+
if (blocked) {
|
|
298
|
+
process.exit(2);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (mergedOutput) {
|
|
302
|
+
process.stdout.write(JSON.stringify(mergedOutput));
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
process.exit(0);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
main().catch((err) => {
|
|
309
|
+
// 오케스트레이터 자체 실패 → 비차단
|
|
310
|
+
process.stderr.write(`[hook-orchestrator] error: ${err.message}\n`);
|
|
311
|
+
process.exit(0);
|
|
312
|
+
});
|