triflux 9.2.3 → 9.3.0

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/bin/triflux.mjs CHANGED
@@ -20,7 +20,7 @@ const CODEX_CONFIG_PATH = join(CODEX_DIR, "config.toml");
20
20
  const PKG = JSON.parse(readFileSync(join(PKG_ROOT, "package.json"), "utf8"));
21
21
 
22
22
  // 이 배열에 포함된 버전에서만 star prompt를 표시한다 (빈 배열 = 모든 버전에서 표시)
23
- const STAR_PROMPT_VERSIONS = ["9.2.2"];
23
+ const STAR_PROMPT_VERSIONS = [];
24
24
 
25
25
  const REQUIRED_CODEX_PROFILES = [
26
26
  {
@@ -161,6 +161,19 @@ const CLI_COMMAND_SCHEMAS = Object.freeze({
161
161
  { name: "command-or-tool", type: "string", description: "예: doctor, setup, delegate, delegate-reply, status" },
162
162
  ],
163
163
  },
164
+ hooks: {
165
+ usage: "tfx hooks <scan|diff|apply|restore|status|set-priority|toggle>",
166
+ description: "훅 우선순위 관리 — 오케스트레이터 적용/복원, 우선순위 조정",
167
+ subcommands: {
168
+ scan: "현재 settings.json 훅 스캔",
169
+ diff: "오케스트레이터 적용 시 변경점 미리보기",
170
+ apply: "오케스트레이터 적용 (settings.json 통합)",
171
+ restore: "원래 settings.json 훅 복원",
172
+ status: "오케스트레이터 적용 상태 확인",
173
+ "set-priority": "특정 훅 우선순위 변경: hooks set-priority <hookId> <priority>",
174
+ toggle: "특정 훅 활성/비활성 토글: hooks toggle <hookId>",
175
+ },
176
+ },
164
177
  hub: {
165
178
  usage: "tfx hub <start|stop|status> [--port N] [--json]",
166
179
  description: "tfx-hub 프로세스 제어",
@@ -863,6 +876,21 @@ function cmdSetup(options = {}) {
863
876
  skillCount++;
864
877
  }
865
878
  }
879
+ // references/ 디렉토리 동기화 (존재하면)
880
+ const refSrc = join(skillsSrc, name, "references");
881
+ const refDst = join(skillsDst, name, "references");
882
+ if (existsSync(refSrc)) {
883
+ mkdirSync(refDst, { recursive: true });
884
+ for (const refFile of readdirSync(refSrc)) {
885
+ const rSrc = join(refSrc, refFile);
886
+ const rDst = join(refDst, refFile);
887
+ if (statSync(rSrc).isFile()) {
888
+ if (!existsSync(rDst) || readFileSync(rSrc, "utf8") !== readFileSync(rDst, "utf8")) {
889
+ copyFileSync(rSrc, rDst);
890
+ }
891
+ }
892
+ }
893
+ }
866
894
  }
867
895
  for (const { alias, source } of SKILL_ALIASES) {
868
896
  const srcDir = join(skillsSrc, source);
@@ -2720,6 +2748,20 @@ async function main() {
2720
2748
  }
2721
2749
  return;
2722
2750
  }
2751
+ case "hooks": {
2752
+ const hookManagerPath = join(PKG_ROOT, "hooks", "hook-manager.mjs");
2753
+ const sub = cmdArgs[0] || "status";
2754
+ try {
2755
+ execFileSync(process.execPath, [hookManagerPath, sub, ...cmdArgs.slice(1)], {
2756
+ stdio: "inherit",
2757
+ timeout: 30000,
2758
+ windowsHide: true,
2759
+ });
2760
+ } catch (e) {
2761
+ if (e.status) process.exitCode = e.status;
2762
+ }
2763
+ return;
2764
+ }
2723
2765
  case "version":
2724
2766
  case "--version":
2725
2767
  case "-v":
@@ -0,0 +1,351 @@
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, copyFileSync } from "node:fs";
16
+ import { join, dirname } from "node:path";
17
+ import { fileURLToPath } from "node:url";
18
+
19
+ const __dirname = dirname(fileURLToPath(import.meta.url));
20
+ const HOME = process.env.HOME || process.env.USERPROFILE || "";
21
+ const SETTINGS_PATH = join(HOME, ".claude", "settings.json");
22
+ const BACKUP_PATH = join(HOME, ".claude", "settings.hooks-backup.json");
23
+ const REGISTRY_PATH = join(__dirname, "hook-registry.json");
24
+ const ORCHESTRATOR_PATH = join(__dirname, "hook-orchestrator.mjs");
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
+ const pluginRoot = process.env.CLAUDE_PLUGIN_ROOT || __dirname.replace(/[\\/]hooks$/, "");
109
+ return cmd
110
+ .replace(/\$\{PLUGIN_ROOT\}/g, pluginRoot)
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 orchestratorCmd = `"${nodeExe}" "${ORCHESTRATOR_PATH}"`;
175
+
176
+ // 모든 이벤트를 하나의 오케스트레이터로 통합
177
+ const newHooks = {};
178
+ const registryEvents = Object.keys(registry.events);
179
+
180
+ // 레지스트리에 없는 기존 이벤트도 보존
181
+ const allEvents = [
182
+ ...new Set([...registryEvents, ...Object.keys(settings.hooks || {})]),
183
+ ];
184
+
185
+ for (const event of allEvents) {
186
+ const registryEntries = registry.events[event] || [];
187
+ const enabledEntries = registryEntries.filter((h) => h.enabled !== false);
188
+
189
+ if (enabledEntries.length > 0) {
190
+ // 레지스트리에 있으면 → 오케스트레이터로 교체
191
+ // 가장 큰 timeout을 기준으로 오케스트레이터 timeout 설정
192
+ const maxTimeout = Math.max(...enabledEntries.map((h) => h.timeout || 10)) + 5;
193
+
194
+ newHooks[event] = [
195
+ {
196
+ matcher: "*",
197
+ hooks: [
198
+ {
199
+ type: "command",
200
+ command: orchestratorCmd,
201
+ timeout: maxTimeout,
202
+ },
203
+ ],
204
+ },
205
+ ];
206
+ } else {
207
+ // 레지스트리에 없으면 기존 유지
208
+ if (settings.hooks?.[event]) {
209
+ newHooks[event] = settings.hooks[event];
210
+ }
211
+ }
212
+ }
213
+
214
+ settings.hooks = newHooks;
215
+ saveJSON(SETTINGS_PATH, settings);
216
+
217
+ return {
218
+ status: "applied",
219
+ message: `오케스트레이터 적용 완료. ${registryEvents.length}개 이벤트가 순차 실행으로 전환됩니다.`,
220
+ events: registryEvents,
221
+ backupPath: BACKUP_PATH,
222
+ };
223
+ }
224
+
225
+ // ── restore: 백업에서 복원 ──────────────────────────────────
226
+
227
+ function restore() {
228
+ if (!existsSync(BACKUP_PATH)) {
229
+ return { status: "no_backup", message: "백업 파일이 없습니다. apply 전에는 복원할 수 없습니다." };
230
+ }
231
+
232
+ const backup = loadJSON(BACKUP_PATH);
233
+ if (!backup?.hooks) {
234
+ return { status: "error", message: "백업 파일이 손상되었습니다." };
235
+ }
236
+
237
+ const settings = loadJSON(SETTINGS_PATH);
238
+ if (!settings) return { status: "error", message: "settings.json을 찾을 수 없습니다." };
239
+
240
+ settings.hooks = backup.hooks;
241
+ saveJSON(SETTINGS_PATH, settings);
242
+
243
+ return {
244
+ status: "restored",
245
+ message: `원래 훅 설정이 복원되었습니다. (백업 시점: ${backup.backedUpAt})`,
246
+ };
247
+ }
248
+
249
+ // ── set-priority: 우선순위 변경 ─────────────────────────────
250
+
251
+ function setPriority(hookId, priority) {
252
+ const registry = loadJSON(REGISTRY_PATH);
253
+ if (!registry) return { status: "error", message: "레지스트리를 찾을 수 없습니다." };
254
+
255
+ const numPriority = parseInt(priority, 10);
256
+ if (isNaN(numPriority)) return { status: "error", message: "priority는 숫자여야 합니다." };
257
+
258
+ let found = false;
259
+ for (const hooks of Object.values(registry.events)) {
260
+ const hook = hooks.find((h) => h.id === hookId);
261
+ if (hook) {
262
+ hook.priority = numPriority;
263
+ found = true;
264
+ break;
265
+ }
266
+ }
267
+
268
+ if (!found) return { status: "not_found", message: `훅 '${hookId}'를 찾을 수 없습니다.` };
269
+
270
+ saveJSON(REGISTRY_PATH, registry);
271
+ return { status: "ok", message: `${hookId}의 우선순위가 ${numPriority}로 변경되었습니다.` };
272
+ }
273
+
274
+ // ── toggle: 활성/비활성 토글 ────────────────────────────────
275
+
276
+ function toggle(hookId) {
277
+ const registry = loadJSON(REGISTRY_PATH);
278
+ if (!registry) return { status: "error", message: "레지스트리를 찾을 수 없습니다." };
279
+
280
+ let found = false;
281
+ let newState = false;
282
+ for (const hooks of Object.values(registry.events)) {
283
+ const hook = hooks.find((h) => h.id === hookId);
284
+ if (hook) {
285
+ hook.enabled = !(hook.enabled !== false);
286
+ newState = hook.enabled;
287
+ found = true;
288
+ break;
289
+ }
290
+ }
291
+
292
+ if (!found) return { status: "not_found", message: `훅 '${hookId}'를 찾을 수 없습니다.` };
293
+
294
+ saveJSON(REGISTRY_PATH, registry);
295
+ return { status: "ok", message: `${hookId}: ${newState ? "활성화" : "비활성화"}` };
296
+ }
297
+
298
+ // ── status: 현재 적용 상태 ──────────────────────────────────
299
+
300
+ function status() {
301
+ const settings = loadJSON(SETTINGS_PATH);
302
+ if (!settings?.hooks) return { orchestrated: false, message: "훅 없음" };
303
+
304
+ let orchestrated = 0;
305
+ let individual = 0;
306
+
307
+ for (const [event, matchers] of Object.entries(settings.hooks)) {
308
+ if (isOrchestrator(matchers)) {
309
+ orchestrated++;
310
+ } else {
311
+ individual++;
312
+ }
313
+ }
314
+
315
+ const hasBackup = existsSync(BACKUP_PATH);
316
+
317
+ return {
318
+ orchestrated: orchestrated > 0,
319
+ orchestratedEvents: orchestrated,
320
+ individualEvents: individual,
321
+ hasBackup,
322
+ message: orchestrated > 0
323
+ ? `오케스트레이터 적용 중: ${orchestrated}개 이벤트 통합, ${individual}개 개별 유지`
324
+ : `오케스트레이터 미적용. ${individual}개 이벤트가 개별 훅으로 실행 중`,
325
+ };
326
+ }
327
+
328
+ // ── CLI 진입점 ──────────────────────────────────────────────
329
+
330
+ const [, , command, ...args] = process.argv;
331
+
332
+ const commands = {
333
+ scan: () => scan(),
334
+ diff: () => diff(),
335
+ apply: () => apply(),
336
+ restore: () => restore(),
337
+ "set-priority": () => setPriority(args[0], args[1]),
338
+ toggle: () => toggle(args[0]),
339
+ status: () => status(),
340
+ };
341
+
342
+ if (!command || !commands[command]) {
343
+ console.log(JSON.stringify({
344
+ error: "사용법: node hook-manager.mjs <scan|diff|apply|restore|set-priority|toggle|status>",
345
+ commands: Object.keys(commands),
346
+ }));
347
+ process.exit(1);
348
+ }
349
+
350
+ const result = commands[command]();
351
+ console.log(JSON.stringify(result, null, 2));
@@ -0,0 +1,264 @@
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 } from "node:child_process";
26
+
27
+ const __dirname = dirname(fileURLToPath(import.meta.url));
28
+ const REGISTRY_PATH =
29
+ process.env.TRIFLUX_HOOK_REGISTRY || join(__dirname, "hook-registry.json");
30
+
31
+ // ── stdin 읽기 ──────────────────────────────────────────────
32
+ function readStdin() {
33
+ try {
34
+ return readFileSync(0, "utf8");
35
+ } catch {
36
+ return "";
37
+ }
38
+ }
39
+
40
+ // ── 레지스트리 로드 ─────────────────────────────────────────
41
+ function loadRegistry() {
42
+ if (!existsSync(REGISTRY_PATH)) return null;
43
+ try {
44
+ return JSON.parse(readFileSync(REGISTRY_PATH, "utf8"));
45
+ } catch {
46
+ return null;
47
+ }
48
+ }
49
+
50
+ // ── 경로 변수 치환 ──────────────────────────────────────────
51
+ function resolveCommand(cmd) {
52
+ const home = process.env.HOME || process.env.USERPROFILE || "";
53
+ const pluginRoot = process.env.CLAUDE_PLUGIN_ROOT || __dirname.replace(/[\\/]hooks$/, "");
54
+
55
+ return cmd
56
+ .replace(/\$\{PLUGIN_ROOT\}/g, pluginRoot)
57
+ .replace(/\$\{HOME\}/g, home)
58
+ .replace(/\$HOME\b/g, home);
59
+ }
60
+
61
+ // ── 매처 매칭 ───────────────────────────────────────────────
62
+ function matchesMatcher(hookMatcher, toolName, eventInput) {
63
+ if (!hookMatcher || hookMatcher === "*") return true;
64
+ if (!toolName) return true;
65
+
66
+ // 파이프 구분 OR 매칭 (예: "Bash|Agent")
67
+ const patterns = hookMatcher.split("|").map((p) => p.trim());
68
+ return patterns.some((p) => {
69
+ try {
70
+ return new RegExp(`^${p}$`).test(toolName);
71
+ } catch {
72
+ return p === toolName;
73
+ }
74
+ });
75
+ }
76
+
77
+ // ── 단일 훅 실행 ────────────────────────────────────────────
78
+ function executeHook(hook, stdinData) {
79
+ const cmd = resolveCommand(hook.command);
80
+ const timeout = (hook.timeout || 10) * 1000;
81
+
82
+ // command 파싱: "node script.mjs" → ["node", ["script.mjs"]]
83
+ // "bash script.sh" → ["bash", ["script.sh"]]
84
+ // 따옴표 처리 포함
85
+ const parts = parseCommand(cmd);
86
+ if (parts.length === 0) return { code: 1, stdout: "", stderr: "empty command" };
87
+
88
+ const [executable, ...args] = parts;
89
+
90
+ try {
91
+ const stdout = execFileSync(executable, args, {
92
+ input: stdinData,
93
+ timeout,
94
+ encoding: "utf8",
95
+ stdio: ["pipe", "pipe", "pipe"],
96
+ windowsHide: true,
97
+ cwd: process.cwd(),
98
+ env: { ...process.env },
99
+ });
100
+ return { code: 0, stdout: stdout || "", stderr: "" };
101
+ } catch (err) {
102
+ const code = err.status ?? 1;
103
+ return {
104
+ code,
105
+ stdout: err.stdout || "",
106
+ stderr: err.stderr || "",
107
+ };
108
+ }
109
+ }
110
+
111
+ // ── 명령어 파싱 (따옴표 처리) ───────────────────────────────
112
+ function parseCommand(cmd) {
113
+ const parts = [];
114
+ let current = "";
115
+ let inQuote = null;
116
+
117
+ for (let i = 0; i < cmd.length; i++) {
118
+ const ch = cmd[i];
119
+ if (inQuote) {
120
+ if (ch === inQuote) {
121
+ inQuote = null;
122
+ } else {
123
+ current += ch;
124
+ }
125
+ } else if (ch === '"' || ch === "'") {
126
+ inQuote = ch;
127
+ } else if (ch === " " || ch === "\t") {
128
+ if (current) {
129
+ parts.push(current);
130
+ current = "";
131
+ }
132
+ } else {
133
+ current += ch;
134
+ }
135
+ }
136
+ if (current) parts.push(current);
137
+ return parts;
138
+ }
139
+
140
+ // ── JSON 출력 머지 ──────────────────────────────────────────
141
+ function mergeOutputs(accumulated, newOutput) {
142
+ if (!newOutput) return accumulated;
143
+
144
+ try {
145
+ const parsed = JSON.parse(newOutput);
146
+ if (!accumulated) return parsed;
147
+
148
+ // hookSpecificOutput는 마지막 것이 이김
149
+ if (parsed.hookSpecificOutput) {
150
+ accumulated.hookSpecificOutput = parsed.hookSpecificOutput;
151
+ }
152
+ // systemMessage는 누적
153
+ if (parsed.systemMessage) {
154
+ accumulated.systemMessage = accumulated.systemMessage
155
+ ? accumulated.systemMessage + "\n" + parsed.systemMessage
156
+ : parsed.systemMessage;
157
+ }
158
+ // additionalContext는 누적
159
+ if (parsed.additionalContext) {
160
+ accumulated.additionalContext = accumulated.additionalContext
161
+ ? accumulated.additionalContext + "\n" + parsed.additionalContext
162
+ : parsed.additionalContext;
163
+ }
164
+ // decision: block이 하나라도 있으면 block
165
+ if (parsed.decision === "block") {
166
+ accumulated.decision = "block";
167
+ accumulated.reason = parsed.reason || accumulated.reason;
168
+ }
169
+ // continue: false가 하나라도 있으면 false
170
+ if (parsed.continue === false) {
171
+ accumulated.continue = false;
172
+ accumulated.stopReason = parsed.stopReason || accumulated.stopReason;
173
+ }
174
+
175
+ return accumulated;
176
+ } catch {
177
+ // JSON이 아니면 additionalContext로 취급
178
+ if (!accumulated) accumulated = {};
179
+ accumulated.additionalContext = accumulated.additionalContext
180
+ ? accumulated.additionalContext + "\n" + newOutput.trim()
181
+ : newOutput.trim();
182
+ return accumulated;
183
+ }
184
+ }
185
+
186
+ // ── 메인 ────────────────────────────────────────────────────
187
+ function main() {
188
+ const stdinRaw = readStdin();
189
+ const registry = loadRegistry();
190
+
191
+ if (!registry) {
192
+ // 레지스트리 없으면 패스스루
193
+ if (stdinRaw.trim()) process.stdout.write(stdinRaw);
194
+ process.exit(0);
195
+ }
196
+
197
+ // stdin에서 이벤트명, 툴명 추출
198
+ let eventName = "";
199
+ let toolName = "";
200
+ if (stdinRaw.trim()) {
201
+ try {
202
+ const input = JSON.parse(stdinRaw);
203
+ eventName = input.hook_event_name || "";
204
+ toolName = input.tool_name || "";
205
+ } catch {
206
+ // 파싱 실패 시 그냥 통과
207
+ process.exit(0);
208
+ }
209
+ }
210
+
211
+ if (!eventName) process.exit(0);
212
+
213
+ // 이벤트에 해당하는 훅 목록
214
+ const hooks = registry.events[eventName];
215
+ if (!hooks || hooks.length === 0) process.exit(0);
216
+
217
+ // 우선순위 정렬 (낮을수록 먼저)
218
+ const sorted = [...hooks]
219
+ .filter((h) => h.enabled !== false)
220
+ .sort((a, b) => (a.priority ?? 999) - (b.priority ?? 999));
221
+
222
+ // 매처 필터링 + 순차 실행
223
+ let mergedOutput = null;
224
+ let blocked = false;
225
+
226
+ for (const hook of sorted) {
227
+ // 매처 체크
228
+ if (!matchesMatcher(hook.matcher, toolName)) continue;
229
+
230
+ const result = executeHook(hook, stdinRaw);
231
+
232
+ if (result.code === 2) {
233
+ // BLOCK — stderr를 에러로 전달하고 즉시 중단
234
+ if (result.stderr) process.stderr.write(result.stderr);
235
+ blocked = true;
236
+ break;
237
+ }
238
+
239
+ if (result.code === 0 && result.stdout.trim()) {
240
+ mergedOutput = mergeOutputs(mergedOutput, result.stdout.trim());
241
+ }
242
+
243
+ // exit 0이 아닌 다른 코드(1, 3+ 등)는 무시하고 계속
244
+ }
245
+
246
+ // 결과 출력
247
+ if (blocked) {
248
+ process.exit(2);
249
+ }
250
+
251
+ if (mergedOutput) {
252
+ process.stdout.write(JSON.stringify(mergedOutput));
253
+ }
254
+
255
+ process.exit(0);
256
+ }
257
+
258
+ try {
259
+ main();
260
+ } catch (err) {
261
+ // 오케스트레이터 자체 실패 → 비차단
262
+ process.stderr.write(`[hook-orchestrator] error: ${err.message}\n`);
263
+ process.exit(0);
264
+ }