triflux 9.2.3 → 9.2.4
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 +28 -1
- package/hooks/hook-manager.mjs +351 -0
- package/hooks/hook-orchestrator.mjs +264 -0
- package/hooks/hook-registry.json +191 -0
- package/hooks/hooks.json +40 -19
- package/package.json +1 -1
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 = [
|
|
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 프로세스 제어",
|
|
@@ -2720,6 +2733,20 @@ async function main() {
|
|
|
2720
2733
|
}
|
|
2721
2734
|
return;
|
|
2722
2735
|
}
|
|
2736
|
+
case "hooks": {
|
|
2737
|
+
const hookManagerPath = join(PKG_ROOT, "hooks", "hook-manager.mjs");
|
|
2738
|
+
const sub = cmdArgs[0] || "status";
|
|
2739
|
+
try {
|
|
2740
|
+
execFileSync(process.execPath, [hookManagerPath, sub, ...cmdArgs.slice(1)], {
|
|
2741
|
+
stdio: "inherit",
|
|
2742
|
+
timeout: 30000,
|
|
2743
|
+
windowsHide: true,
|
|
2744
|
+
});
|
|
2745
|
+
} catch (e) {
|
|
2746
|
+
if (e.status) process.exitCode = e.status;
|
|
2747
|
+
}
|
|
2748
|
+
return;
|
|
2749
|
+
}
|
|
2723
2750
|
case "version":
|
|
2724
2751
|
case "--version":
|
|
2725
2752
|
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
|
+
}
|
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "hook-registry-schema",
|
|
3
|
+
"version": 1,
|
|
4
|
+
"description": "훅 우선순위 레지스트리 — 오케스트레이터가 이 파일을 읽어 순차 실행한다. priority 낮을수록 먼저 실행.",
|
|
5
|
+
"defaults": {
|
|
6
|
+
"triflux_priority": 0,
|
|
7
|
+
"omc_priority": 50,
|
|
8
|
+
"external_priority": 100
|
|
9
|
+
},
|
|
10
|
+
"events": {
|
|
11
|
+
"PreToolUse": [
|
|
12
|
+
{
|
|
13
|
+
"id": "tfx-safety-guard",
|
|
14
|
+
"source": "triflux",
|
|
15
|
+
"matcher": "Bash",
|
|
16
|
+
"command": "node \"${PLUGIN_ROOT}/hooks/safety-guard.mjs\"",
|
|
17
|
+
"priority": 0,
|
|
18
|
+
"enabled": true,
|
|
19
|
+
"timeout": 3,
|
|
20
|
+
"blocking": true,
|
|
21
|
+
"description": "위험 Bash 명령 사전 차단 (rm -rf, force push 등)"
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
"id": "tfx-agent-route-guard",
|
|
25
|
+
"source": "triflux",
|
|
26
|
+
"matcher": "Agent",
|
|
27
|
+
"command": "node \"${PLUGIN_ROOT}/hooks/agent-route-guard.mjs\"",
|
|
28
|
+
"priority": 0,
|
|
29
|
+
"enabled": true,
|
|
30
|
+
"timeout": 3,
|
|
31
|
+
"blocking": false,
|
|
32
|
+
"description": "서브에이전트 스폰 시 triflux 컨텍스트 주입"
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
"id": "omc-headless-guard",
|
|
36
|
+
"source": "omc",
|
|
37
|
+
"matcher": "Bash|Agent",
|
|
38
|
+
"command": "bash \"${HOME}/.claude/scripts/headless-guard-fast.sh\"",
|
|
39
|
+
"priority": 50,
|
|
40
|
+
"enabled": true,
|
|
41
|
+
"timeout": 3,
|
|
42
|
+
"blocking": false,
|
|
43
|
+
"description": "psmux headless 가드"
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
"id": "omc-tfx-gate-activate",
|
|
47
|
+
"source": "omc",
|
|
48
|
+
"matcher": "Skill",
|
|
49
|
+
"command": "node \"${HOME}/.claude/scripts/tfx-gate-activate.mjs\"",
|
|
50
|
+
"priority": 50,
|
|
51
|
+
"enabled": true,
|
|
52
|
+
"timeout": 2,
|
|
53
|
+
"blocking": false,
|
|
54
|
+
"description": "tfx-multi 상태 추적 게이트"
|
|
55
|
+
}
|
|
56
|
+
],
|
|
57
|
+
"PostToolUse": [
|
|
58
|
+
{
|
|
59
|
+
"id": "tfx-cross-review-tracker",
|
|
60
|
+
"source": "triflux",
|
|
61
|
+
"matcher": "Edit|Write",
|
|
62
|
+
"command": "node \"${PLUGIN_ROOT}/hooks/cross-review-tracker.mjs\"",
|
|
63
|
+
"priority": 0,
|
|
64
|
+
"enabled": true,
|
|
65
|
+
"timeout": 3,
|
|
66
|
+
"blocking": false,
|
|
67
|
+
"description": "파일 수정 추적 → 교차 리뷰 nudge"
|
|
68
|
+
}
|
|
69
|
+
],
|
|
70
|
+
"PostToolUseFailure": [
|
|
71
|
+
{
|
|
72
|
+
"id": "tfx-error-context",
|
|
73
|
+
"source": "triflux",
|
|
74
|
+
"matcher": "*",
|
|
75
|
+
"command": "node \"${PLUGIN_ROOT}/hooks/error-context.mjs\"",
|
|
76
|
+
"priority": 0,
|
|
77
|
+
"enabled": true,
|
|
78
|
+
"timeout": 3,
|
|
79
|
+
"blocking": false,
|
|
80
|
+
"description": "도구 실패 시 에러 힌트 자동 주입"
|
|
81
|
+
}
|
|
82
|
+
],
|
|
83
|
+
"UserPromptSubmit": [
|
|
84
|
+
{
|
|
85
|
+
"id": "tfx-keyword-detector",
|
|
86
|
+
"source": "triflux",
|
|
87
|
+
"matcher": "*",
|
|
88
|
+
"command": "node \"${PLUGIN_ROOT}/scripts/run.cjs\" \"${PLUGIN_ROOT}/scripts/keyword-detector.mjs\"",
|
|
89
|
+
"priority": 0,
|
|
90
|
+
"enabled": true,
|
|
91
|
+
"timeout": 5,
|
|
92
|
+
"blocking": false,
|
|
93
|
+
"description": "키워드 매칭 → 스킬 자동 라우팅"
|
|
94
|
+
}
|
|
95
|
+
],
|
|
96
|
+
"SessionStart": [
|
|
97
|
+
{
|
|
98
|
+
"id": "tfx-setup",
|
|
99
|
+
"source": "triflux",
|
|
100
|
+
"matcher": "*",
|
|
101
|
+
"command": "node \"${PLUGIN_ROOT}/scripts/setup.mjs\"",
|
|
102
|
+
"priority": 0,
|
|
103
|
+
"enabled": true,
|
|
104
|
+
"timeout": 10,
|
|
105
|
+
"blocking": false,
|
|
106
|
+
"description": "triflux 환경 초기화"
|
|
107
|
+
},
|
|
108
|
+
{
|
|
109
|
+
"id": "tfx-hub-ensure",
|
|
110
|
+
"source": "triflux",
|
|
111
|
+
"matcher": "*",
|
|
112
|
+
"command": "node \"${PLUGIN_ROOT}/scripts/hub-ensure.mjs\"",
|
|
113
|
+
"priority": 1,
|
|
114
|
+
"enabled": true,
|
|
115
|
+
"timeout": 8,
|
|
116
|
+
"blocking": false,
|
|
117
|
+
"description": "tfx-hub 서비스 헬스체크 및 시작"
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
"id": "tfx-preflight-cache",
|
|
121
|
+
"source": "triflux",
|
|
122
|
+
"matcher": "*",
|
|
123
|
+
"command": "node \"${PLUGIN_ROOT}/scripts/preflight-cache.mjs\"",
|
|
124
|
+
"priority": 2,
|
|
125
|
+
"enabled": true,
|
|
126
|
+
"timeout": 5,
|
|
127
|
+
"blocking": false,
|
|
128
|
+
"description": "CLI/Hub 가용성 캐시"
|
|
129
|
+
},
|
|
130
|
+
{
|
|
131
|
+
"id": "ext-session-vault-start",
|
|
132
|
+
"source": "session-vault",
|
|
133
|
+
"matcher": "*",
|
|
134
|
+
"command": "${HOME}/Desktop/Projects/cli/session-vault/scripts/start_hook.sh",
|
|
135
|
+
"priority": 100,
|
|
136
|
+
"enabled": true,
|
|
137
|
+
"timeout": 10,
|
|
138
|
+
"blocking": false,
|
|
139
|
+
"description": "세션 볼트 로깅 시작"
|
|
140
|
+
}
|
|
141
|
+
],
|
|
142
|
+
"Stop": [
|
|
143
|
+
{
|
|
144
|
+
"id": "tfx-pipeline-stop",
|
|
145
|
+
"source": "triflux",
|
|
146
|
+
"matcher": "*",
|
|
147
|
+
"command": "node \"${PLUGIN_ROOT}/hooks/pipeline-stop.mjs\"",
|
|
148
|
+
"priority": 0,
|
|
149
|
+
"enabled": true,
|
|
150
|
+
"timeout": 5,
|
|
151
|
+
"blocking": true,
|
|
152
|
+
"description": "활성 파이프라인 감지 → 종료 차단"
|
|
153
|
+
},
|
|
154
|
+
{
|
|
155
|
+
"id": "ext-session-vault-export",
|
|
156
|
+
"source": "session-vault",
|
|
157
|
+
"matcher": "*",
|
|
158
|
+
"command": "${HOME}/Desktop/Projects/cli/session-vault/scripts/export_hook.sh",
|
|
159
|
+
"priority": 100,
|
|
160
|
+
"enabled": true,
|
|
161
|
+
"timeout": 30,
|
|
162
|
+
"blocking": false,
|
|
163
|
+
"description": "세션 트랜스크립트 내보내기"
|
|
164
|
+
},
|
|
165
|
+
{
|
|
166
|
+
"id": "ext-mcp-cleanup",
|
|
167
|
+
"source": "system",
|
|
168
|
+
"matcher": "*",
|
|
169
|
+
"command": "powershell -NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File \"${HOME}/.claude/scripts/mcp-cleanup.ps1\"",
|
|
170
|
+
"priority": 100,
|
|
171
|
+
"enabled": true,
|
|
172
|
+
"timeout": 8,
|
|
173
|
+
"blocking": false,
|
|
174
|
+
"description": "MCP 고아 프로세스 정리"
|
|
175
|
+
}
|
|
176
|
+
],
|
|
177
|
+
"SubagentStop": [
|
|
178
|
+
{
|
|
179
|
+
"id": "tfx-subagent-verifier",
|
|
180
|
+
"source": "triflux",
|
|
181
|
+
"matcher": "*",
|
|
182
|
+
"command": "node \"${PLUGIN_ROOT}/hooks/subagent-verifier.mjs\"",
|
|
183
|
+
"priority": 0,
|
|
184
|
+
"enabled": true,
|
|
185
|
+
"timeout": 3,
|
|
186
|
+
"blocking": false,
|
|
187
|
+
"description": "서브에이전트 결과 품질 체크"
|
|
188
|
+
}
|
|
189
|
+
]
|
|
190
|
+
}
|
|
191
|
+
}
|
package/hooks/hooks.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"description": "triflux lifecycle hooks —
|
|
2
|
+
"description": "triflux lifecycle hooks v2 — hook-orchestrator 기반 순차 실행. 우선순위는 hook-registry.json에서 관리.",
|
|
3
3
|
"hooks": {
|
|
4
4
|
"SessionStart": [
|
|
5
5
|
{
|
|
@@ -7,58 +7,79 @@
|
|
|
7
7
|
"hooks": [
|
|
8
8
|
{
|
|
9
9
|
"type": "command",
|
|
10
|
-
"command": "node \"${CLAUDE_PLUGIN_ROOT}/
|
|
11
|
-
"timeout":
|
|
12
|
-
}
|
|
10
|
+
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/hook-orchestrator.mjs\"",
|
|
11
|
+
"timeout": 15
|
|
12
|
+
}
|
|
13
|
+
]
|
|
14
|
+
}
|
|
15
|
+
],
|
|
16
|
+
"UserPromptSubmit": [
|
|
17
|
+
{
|
|
18
|
+
"matcher": "*",
|
|
19
|
+
"hooks": [
|
|
13
20
|
{
|
|
14
21
|
"type": "command",
|
|
15
|
-
"command": "node \"${CLAUDE_PLUGIN_ROOT}/
|
|
16
|
-
"timeout":
|
|
17
|
-
}
|
|
22
|
+
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/hook-orchestrator.mjs\"",
|
|
23
|
+
"timeout": 10
|
|
24
|
+
}
|
|
25
|
+
]
|
|
26
|
+
}
|
|
27
|
+
],
|
|
28
|
+
"PreToolUse": [
|
|
29
|
+
{
|
|
30
|
+
"matcher": "Bash|Agent|Skill",
|
|
31
|
+
"hooks": [
|
|
18
32
|
{
|
|
19
33
|
"type": "command",
|
|
20
|
-
"command": "node \"${CLAUDE_PLUGIN_ROOT}/
|
|
21
|
-
"timeout":
|
|
22
|
-
}
|
|
34
|
+
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/hook-orchestrator.mjs\"",
|
|
35
|
+
"timeout": 10
|
|
36
|
+
}
|
|
37
|
+
]
|
|
38
|
+
}
|
|
39
|
+
],
|
|
40
|
+
"PostToolUse": [
|
|
41
|
+
{
|
|
42
|
+
"matcher": "Edit|Write",
|
|
43
|
+
"hooks": [
|
|
23
44
|
{
|
|
24
45
|
"type": "command",
|
|
25
|
-
"command": "node \"${CLAUDE_PLUGIN_ROOT}/
|
|
46
|
+
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/hook-orchestrator.mjs\"",
|
|
26
47
|
"timeout": 5
|
|
27
48
|
}
|
|
28
49
|
]
|
|
29
50
|
}
|
|
30
51
|
],
|
|
31
|
-
"
|
|
52
|
+
"PostToolUseFailure": [
|
|
32
53
|
{
|
|
33
54
|
"matcher": "*",
|
|
34
55
|
"hooks": [
|
|
35
56
|
{
|
|
36
57
|
"type": "command",
|
|
37
|
-
"command": "node \"${CLAUDE_PLUGIN_ROOT}/
|
|
58
|
+
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/hook-orchestrator.mjs\"",
|
|
38
59
|
"timeout": 5
|
|
39
60
|
}
|
|
40
61
|
]
|
|
41
62
|
}
|
|
42
63
|
],
|
|
43
|
-
"
|
|
64
|
+
"Stop": [
|
|
44
65
|
{
|
|
45
|
-
"matcher": "
|
|
66
|
+
"matcher": "*",
|
|
46
67
|
"hooks": [
|
|
47
68
|
{
|
|
48
69
|
"type": "command",
|
|
49
|
-
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/
|
|
50
|
-
"timeout":
|
|
70
|
+
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/hook-orchestrator.mjs\"",
|
|
71
|
+
"timeout": 35
|
|
51
72
|
}
|
|
52
73
|
]
|
|
53
74
|
}
|
|
54
75
|
],
|
|
55
|
-
"
|
|
76
|
+
"SubagentStop": [
|
|
56
77
|
{
|
|
57
78
|
"matcher": "*",
|
|
58
79
|
"hooks": [
|
|
59
80
|
{
|
|
60
81
|
"type": "command",
|
|
61
|
-
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/
|
|
82
|
+
"command": "node \"${CLAUDE_PLUGIN_ROOT}/hooks/hook-orchestrator.mjs\"",
|
|
62
83
|
"timeout": 5
|
|
63
84
|
}
|
|
64
85
|
]
|