@triflux/core 10.49.0 → 10.50.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/agy-session-hook.mjs +1 -1
- package/hooks/claude-cwd-projection-refresh.mjs +7 -79
- package/hooks/codex-session-hook.mjs +3 -8
- package/hooks/hook-orchestrator.mjs +8 -648
- package/hooks/lib/resolve-root.mjs +1 -1
- package/hub/bridge.mjs +290 -51
- package/hub/team/claude-daemon-control.mjs +54 -6
- package/hub/team/claude-transcript.mjs +240 -0
- package/hub/team/session-context.mjs +203 -0
- package/hud/constants.mjs +10 -0
- package/hud/hud-qos-status.mjs +23 -4
- package/hud/providers/gemini.mjs +4 -1
- package/hud/renderers.mjs +10 -6
- package/package.json +1 -1
- package/scripts/lib/doctor-env-checks.mjs +0 -85
- package/scripts/lib/legacy-hook-cleanup.mjs +230 -0
- package/scripts/lib/legacy-mcp-cleanup.mjs +772 -0
- package/scripts/lib/mcp-guard-engine.mjs +44 -271
- package/scripts/lib/mcp-manifest.mjs +2 -2
- package/scripts/lib/session-presence.mjs +249 -0
- package/hooks/agent-route-guard.mjs +0 -113
- package/hooks/cross-review-tracker.mjs +0 -135
- package/hooks/cto-north-star-brief.mjs +0 -133
- package/hooks/error-context.mjs +0 -185
- package/hooks/hook-adaptive-collector.mjs +0 -88
- package/hooks/hook-manager.mjs +0 -426
- package/hooks/hook-registry.json +0 -367
- package/hooks/hooks.json +0 -149
- package/hooks/keyword-rules.json +0 -676
- package/hooks/mcp-config-watcher.mjs +0 -84
- package/hooks/permission-safe-allow.mjs +0 -267
- package/hooks/pipeline-stop.mjs +0 -435
- package/hooks/post-tool-tips.mjs +0 -306
- package/hooks/pre-compact-snapshot.mjs +0 -181
- package/hooks/safety-guard.mjs +0 -466
- package/hooks/session-end-cleanup.mjs +0 -360
- package/hooks/session-start-fast.mjs +0 -579
- package/hooks/session-start-lake.mjs +0 -81
- package/hooks/subagent-tracker.mjs +0 -281
- package/hooks/subagent-verifier.mjs +0 -85
- package/scripts/lib/cross-review-utils.mjs +0 -59
- package/scripts/lib/hook-utils.mjs +0 -16
- package/scripts/lib/keyword-rules.mjs +0 -296
- package/scripts/lib/mcp-gateway-health-check.mjs +0 -224
- package/scripts/lib/mcp-gateway-servers.mjs +0 -70
- package/scripts/lib/mcp-gateway-wrapper-check.mjs +0 -35
|
@@ -1,651 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
// ADR-0023 전환 stub. 다음 릴리스에서 지운다.
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
import { cleanupLegacyHooks } from "../scripts/lib/legacy-hook-cleanup.mjs";
|
|
2
5
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
//
|
|
8
|
-
// 실행 규칙:
|
|
9
|
-
// - priority 낮을수록 먼저 실행 (triflux=0, omc=50, external=100)
|
|
10
|
-
// - blocking:true 훅이 exit 2 반환 → 즉시 중단, 이후 훅 건너뜀
|
|
11
|
-
// - 출력(stdout JSON)은 마지막 유효 출력으로 머지
|
|
12
|
-
// - 훅 실패(exit !0 && !2)는 무시하고 다음 훅 진행
|
|
13
|
-
//
|
|
14
|
-
// 사용법:
|
|
15
|
-
// settings.json에서:
|
|
16
|
-
// { "type": "command", "command": "node .../hook-orchestrator.mjs", "timeout": 30 }
|
|
17
|
-
//
|
|
18
|
-
// 환경변수:
|
|
19
|
-
// TRIFLUX_HOOK_REGISTRY — registry 경로 오버라이드
|
|
20
|
-
// CLAUDE_PLUGIN_ROOT — ${PLUGIN_ROOT} 치환용
|
|
21
|
-
// HOME / USERPROFILE — ${HOME} 치환용
|
|
22
|
-
|
|
23
|
-
import { execFile, execFileSync } from "node:child_process";
|
|
24
|
-
import { createHash } from "node:crypto";
|
|
25
|
-
import {
|
|
26
|
-
existsSync,
|
|
27
|
-
mkdirSync,
|
|
28
|
-
readFileSync,
|
|
29
|
-
unlinkSync,
|
|
30
|
-
writeFileSync,
|
|
31
|
-
} from "node:fs";
|
|
32
|
-
import { tmpdir } from "node:os";
|
|
33
|
-
import { dirname, join } from "node:path";
|
|
34
|
-
import { fileURLToPath } from "node:url";
|
|
35
|
-
import { PLUGIN_ROOT } from "./lib/resolve-root.mjs";
|
|
36
|
-
|
|
37
|
-
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
38
|
-
const REGISTRY_PATH =
|
|
39
|
-
process.env.TRIFLUX_HOOK_REGISTRY || join(__dirname, "hook-registry.json");
|
|
40
|
-
const HOOK_CACHE_DIR =
|
|
41
|
-
process.env.TRIFLUX_HOOK_CACHE_DIR ||
|
|
42
|
-
join(tmpdir(), "triflux-hook-orchestrator-cache");
|
|
43
|
-
const HOOK_CACHE_TTL_MS = Math.max(
|
|
44
|
-
0,
|
|
45
|
-
Number.parseInt(process.env.TRIFLUX_HOOK_CACHE_TTL_MS || "1500", 10) || 1500,
|
|
46
|
-
);
|
|
47
|
-
|
|
48
|
-
// ── stdin 읽기 ──────────────────────────────────────────────
|
|
49
|
-
function readStdin() {
|
|
50
|
-
try {
|
|
51
|
-
return readFileSync(0, "utf8");
|
|
52
|
-
} catch {
|
|
53
|
-
return "";
|
|
54
|
-
}
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
// ── 레지스트리 로드 ─────────────────────────────────────────
|
|
58
|
-
function loadRegistry() {
|
|
59
|
-
if (!existsSync(REGISTRY_PATH)) return null;
|
|
60
|
-
try {
|
|
61
|
-
return JSON.parse(readFileSync(REGISTRY_PATH, "utf8"));
|
|
62
|
-
} catch {
|
|
63
|
-
return null;
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
// ── 경로 변수 치환 ──────────────────────────────────────────
|
|
68
|
-
function resolveCommand(cmd) {
|
|
69
|
-
const home = process.env.HOME || process.env.USERPROFILE || "";
|
|
70
|
-
|
|
71
|
-
return cmd
|
|
72
|
-
.replace(/\$\{PLUGIN_ROOT\}/g, PLUGIN_ROOT)
|
|
73
|
-
.replace(/\$\{CLAUDE_PLUGIN_ROOT\}/g, PLUGIN_ROOT)
|
|
74
|
-
.replace(/\$\{HOME\}/g, home)
|
|
75
|
-
.replace(/\$HOME\b/g, home);
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
// ── 매처 매칭 ───────────────────────────────────────────────
|
|
79
|
-
function matchesMatcher(hookMatcher, toolName, eventInput) {
|
|
80
|
-
if (!hookMatcher || hookMatcher === "*") return true;
|
|
81
|
-
if (!toolName) return true;
|
|
82
|
-
|
|
83
|
-
// 파이프 구분 OR 매칭 (예: "Bash|Agent")
|
|
84
|
-
const patterns = hookMatcher.split("|").map((p) => p.trim());
|
|
85
|
-
return patterns.some((p) => {
|
|
86
|
-
try {
|
|
87
|
-
return new RegExp(`^${p}$`).test(toolName);
|
|
88
|
-
} catch {
|
|
89
|
-
return p === toolName;
|
|
90
|
-
}
|
|
91
|
-
});
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
function shouldDedupePreToolUseBash(eventName, toolName) {
|
|
95
|
-
return eventName === "PreToolUse" && toolName === "Bash";
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
function buildHookCachePath(stdinData) {
|
|
99
|
-
const digest = createHash("sha1").update(stdinData).digest("hex");
|
|
100
|
-
return join(HOOK_CACHE_DIR, `pretool-bash-${digest}.json`);
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
function readHookCache(stdinData) {
|
|
104
|
-
if (HOOK_CACHE_TTL_MS <= 0) return null;
|
|
105
|
-
|
|
106
|
-
const cachePath = buildHookCachePath(stdinData);
|
|
107
|
-
if (!existsSync(cachePath)) return null;
|
|
108
|
-
|
|
109
|
-
try {
|
|
110
|
-
const cached = JSON.parse(readFileSync(cachePath, "utf8"));
|
|
111
|
-
const ageMs = Date.now() - Number(cached?.ts || 0);
|
|
112
|
-
if (!Number.isFinite(ageMs) || ageMs < 0 || ageMs > HOOK_CACHE_TTL_MS) {
|
|
113
|
-
unlinkSync(cachePath);
|
|
114
|
-
return null;
|
|
115
|
-
}
|
|
116
|
-
return cached;
|
|
117
|
-
} catch {
|
|
118
|
-
try {
|
|
119
|
-
unlinkSync(cachePath);
|
|
120
|
-
} catch {}
|
|
121
|
-
return null;
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
function writeHookCache(stdinData, payload) {
|
|
126
|
-
if (HOOK_CACHE_TTL_MS <= 0) return;
|
|
127
|
-
|
|
128
|
-
try {
|
|
129
|
-
mkdirSync(HOOK_CACHE_DIR, { recursive: true });
|
|
130
|
-
writeFileSync(
|
|
131
|
-
buildHookCachePath(stdinData),
|
|
132
|
-
JSON.stringify({ ts: Date.now(), ...payload }),
|
|
133
|
-
"utf8",
|
|
134
|
-
);
|
|
135
|
-
} catch {
|
|
136
|
-
// 캐시 실패는 훅 실행에 영향 주지 않음
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
function shouldShortCircuitPreToolUseBash(result) {
|
|
141
|
-
if (!result || result.code === 2) return true;
|
|
142
|
-
return result.code === 0 && Boolean(result.stdout?.trim());
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
// ── 단일 훅 실행 ────────────────────────────────────────────
|
|
146
|
-
function executeHook(hook, stdinData) {
|
|
147
|
-
const cmd = resolveCommand(hook.command);
|
|
148
|
-
const timeout = (hook.timeout || 10) * 1000;
|
|
149
|
-
|
|
150
|
-
// command 파싱: "node script.mjs" → ["node", ["script.mjs"]]
|
|
151
|
-
// "bash script.sh" → ["bash", ["script.sh"]]
|
|
152
|
-
// 따옴표 처리 포함
|
|
153
|
-
const parts = parseCommand(cmd);
|
|
154
|
-
if (parts.length === 0)
|
|
155
|
-
return { code: 1, stdout: "", stderr: "empty command" };
|
|
156
|
-
|
|
157
|
-
const [executable, ...args] = parts;
|
|
158
|
-
|
|
159
|
-
try {
|
|
160
|
-
const stdout = execFileSync(executable, args, {
|
|
161
|
-
input: stdinData,
|
|
162
|
-
timeout,
|
|
163
|
-
encoding: "utf8",
|
|
164
|
-
stdio: ["pipe", "pipe", "pipe"],
|
|
165
|
-
windowsHide: true,
|
|
166
|
-
cwd: process.cwd(),
|
|
167
|
-
env: { ...process.env },
|
|
168
|
-
});
|
|
169
|
-
return { code: 0, stdout: stdout || "", stderr: "" };
|
|
170
|
-
} catch (err) {
|
|
171
|
-
const code = err.status ?? 1;
|
|
172
|
-
return {
|
|
173
|
-
code,
|
|
174
|
-
stdout: err.stdout || "",
|
|
175
|
-
stderr: err.stderr || "",
|
|
176
|
-
};
|
|
177
|
-
}
|
|
178
|
-
}
|
|
179
|
-
|
|
180
|
-
function executeHookAsync(hook, stdinData) {
|
|
181
|
-
return new Promise((resolve) => {
|
|
182
|
-
const cmd = resolveCommand(hook.command);
|
|
183
|
-
const timeout = (hook.timeout || 10) * 1000;
|
|
184
|
-
const parts = parseCommand(cmd);
|
|
185
|
-
if (parts.length === 0) {
|
|
186
|
-
resolve({ code: 1, stdout: "", stderr: "empty command" });
|
|
187
|
-
return;
|
|
188
|
-
}
|
|
189
|
-
const [executable, ...args] = parts;
|
|
190
|
-
const child = execFile(
|
|
191
|
-
executable,
|
|
192
|
-
args,
|
|
193
|
-
{
|
|
194
|
-
timeout,
|
|
195
|
-
encoding: "utf8",
|
|
196
|
-
windowsHide: true,
|
|
197
|
-
cwd: process.cwd(),
|
|
198
|
-
env: { ...process.env },
|
|
199
|
-
},
|
|
200
|
-
(err, stdout, stderr) => {
|
|
201
|
-
if (err)
|
|
202
|
-
resolve({
|
|
203
|
-
code: err.status ?? 1,
|
|
204
|
-
stdout: stdout || "",
|
|
205
|
-
stderr: stderr || "",
|
|
206
|
-
});
|
|
207
|
-
else resolve({ code: 0, stdout: stdout || "", stderr: "" });
|
|
208
|
-
},
|
|
209
|
-
);
|
|
210
|
-
if (stdinData) {
|
|
211
|
-
child.stdin.write(stdinData);
|
|
212
|
-
child.stdin.end();
|
|
213
|
-
} else child.stdin.end();
|
|
214
|
-
});
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
// ── 명령어 파싱 (따옴표 처리) ───────────────────────────────
|
|
218
|
-
function parseCommand(cmd) {
|
|
219
|
-
const parts = [];
|
|
220
|
-
let current = "";
|
|
221
|
-
let inQuote = null;
|
|
222
|
-
|
|
223
|
-
for (let i = 0; i < cmd.length; i++) {
|
|
224
|
-
const ch = cmd[i];
|
|
225
|
-
if (inQuote) {
|
|
226
|
-
if (ch === inQuote) {
|
|
227
|
-
inQuote = null;
|
|
228
|
-
} else {
|
|
229
|
-
current += ch;
|
|
230
|
-
}
|
|
231
|
-
} else if (ch === '"' || ch === "'") {
|
|
232
|
-
inQuote = ch;
|
|
233
|
-
} else if (ch === " " || ch === "\t") {
|
|
234
|
-
if (current) {
|
|
235
|
-
parts.push(current);
|
|
236
|
-
current = "";
|
|
237
|
-
}
|
|
238
|
-
} else {
|
|
239
|
-
current += ch;
|
|
240
|
-
}
|
|
241
|
-
}
|
|
242
|
-
if (current) parts.push(current);
|
|
243
|
-
return parts;
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
// ── JSON 출력 머지 ──────────────────────────────────────────
|
|
247
|
-
function mergeOutputs(accumulated, newOutput) {
|
|
248
|
-
if (!newOutput) return accumulated;
|
|
249
|
-
|
|
250
|
-
try {
|
|
251
|
-
const parsed = JSON.parse(newOutput);
|
|
252
|
-
if (!accumulated) return parsed;
|
|
253
|
-
|
|
254
|
-
// hookSpecificOutput 머지 — additionalContext는 누적, 나머지는 덮어쓰기
|
|
255
|
-
if (parsed.hookSpecificOutput) {
|
|
256
|
-
if (
|
|
257
|
-
accumulated.hookSpecificOutput?.additionalContext &&
|
|
258
|
-
parsed.hookSpecificOutput.additionalContext
|
|
259
|
-
) {
|
|
260
|
-
parsed.hookSpecificOutput.additionalContext =
|
|
261
|
-
accumulated.hookSpecificOutput.additionalContext +
|
|
262
|
-
"\n" +
|
|
263
|
-
parsed.hookSpecificOutput.additionalContext;
|
|
264
|
-
}
|
|
265
|
-
accumulated.hookSpecificOutput = {
|
|
266
|
-
...accumulated.hookSpecificOutput,
|
|
267
|
-
...parsed.hookSpecificOutput,
|
|
268
|
-
};
|
|
269
|
-
}
|
|
270
|
-
// systemMessage는 누적
|
|
271
|
-
if (parsed.systemMessage) {
|
|
272
|
-
accumulated.systemMessage = accumulated.systemMessage
|
|
273
|
-
? accumulated.systemMessage + "\n" + parsed.systemMessage
|
|
274
|
-
: parsed.systemMessage;
|
|
275
|
-
}
|
|
276
|
-
// additionalContext는 누적
|
|
277
|
-
if (parsed.additionalContext) {
|
|
278
|
-
accumulated.additionalContext = accumulated.additionalContext
|
|
279
|
-
? accumulated.additionalContext + "\n" + parsed.additionalContext
|
|
280
|
-
: parsed.additionalContext;
|
|
281
|
-
}
|
|
282
|
-
// decision: block이 하나라도 있으면 block
|
|
283
|
-
if (parsed.decision === "block") {
|
|
284
|
-
accumulated.decision = "block";
|
|
285
|
-
accumulated.reason = parsed.reason || accumulated.reason;
|
|
286
|
-
}
|
|
287
|
-
// continue: false가 하나라도 있으면 false
|
|
288
|
-
if (parsed.continue === false) {
|
|
289
|
-
accumulated.continue = false;
|
|
290
|
-
accumulated.stopReason = parsed.stopReason || accumulated.stopReason;
|
|
291
|
-
}
|
|
292
|
-
|
|
293
|
-
return accumulated;
|
|
294
|
-
} catch {
|
|
295
|
-
// JSON이 아니면 additionalContext로 취급
|
|
296
|
-
if (!accumulated) accumulated = {};
|
|
297
|
-
accumulated.additionalContext = accumulated.additionalContext
|
|
298
|
-
? accumulated.additionalContext + "\n" + newOutput.trim()
|
|
299
|
-
: newOutput.trim();
|
|
300
|
-
return accumulated;
|
|
301
|
-
}
|
|
6
|
+
try {
|
|
7
|
+
readFileSync(0);
|
|
8
|
+
cleanupLegacyHooks();
|
|
9
|
+
} catch {
|
|
10
|
+
// 오래된 Claude hook이 남아 있어도 세션 시작을 막지 않는다.
|
|
302
11
|
}
|
|
303
|
-
|
|
304
|
-
// ── 라우팅 가중치 기록 ────────────────────────────────────────
|
|
305
|
-
function recordRouteOutcome(slug, mode, outcome) {
|
|
306
|
-
const home = process.env.HOME || process.env.USERPROFILE || "";
|
|
307
|
-
const projDir = join(home, ".gstack", "projects", slug);
|
|
308
|
-
const weightsPath = join(projDir, "routing-weights.json");
|
|
309
|
-
|
|
310
|
-
mkdirSync(projDir, { recursive: true });
|
|
311
|
-
|
|
312
|
-
const weights = existsSync(weightsPath)
|
|
313
|
-
? JSON.parse(readFileSync(weightsPath, "utf8"))
|
|
314
|
-
: {
|
|
315
|
-
updated_at: null,
|
|
316
|
-
total_routes: 0,
|
|
317
|
-
overrides: 0,
|
|
318
|
-
weights: { mode_bias: {}, profile_bias: {}, depth_bias: {} },
|
|
319
|
-
};
|
|
320
|
-
|
|
321
|
-
weights.total_routes++;
|
|
322
|
-
weights.updated_at = new Date().toISOString();
|
|
323
|
-
|
|
324
|
-
const bias = weights.weights.mode_bias;
|
|
325
|
-
const current = bias[mode] || 0;
|
|
326
|
-
|
|
327
|
-
if (outcome === "override") {
|
|
328
|
-
bias[mode] = Math.max(0, current - 0.1);
|
|
329
|
-
weights.overrides++;
|
|
330
|
-
} else if (outcome === "completion") {
|
|
331
|
-
bias[mode] = Math.min(1, current + 0.05);
|
|
332
|
-
} else if (outcome === "abort") {
|
|
333
|
-
bias[mode] = Math.max(0, current - 0.1);
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
const total = Object.values(bias).reduce((s, v) => s + v, 0);
|
|
337
|
-
if (total > 0) {
|
|
338
|
-
for (const key of Object.keys(bias)) {
|
|
339
|
-
bias[key] = +(bias[key] / total).toFixed(3);
|
|
340
|
-
}
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
writeFileSync(weightsPath, JSON.stringify(weights, null, 2), "utf8");
|
|
344
|
-
}
|
|
345
|
-
|
|
346
|
-
function isHubTokenMonitorSnapshot(snapshot) {
|
|
347
|
-
if (!snapshot || typeof snapshot !== "object") return false;
|
|
348
|
-
return (
|
|
349
|
-
Object.hasOwn(snapshot, "requestTokens") ||
|
|
350
|
-
Object.hasOwn(snapshot, "responseTokens") ||
|
|
351
|
-
Object.hasOwn(snapshot, "totalUpdates") ||
|
|
352
|
-
(snapshot.byTool && typeof snapshot.byTool === "object")
|
|
353
|
-
);
|
|
354
|
-
}
|
|
355
|
-
|
|
356
|
-
function readCompactNudgePercent(snapshot) {
|
|
357
|
-
if (isHubTokenMonitorSnapshot(snapshot)) return null;
|
|
358
|
-
const percent = Number(snapshot?.percent ?? 0);
|
|
359
|
-
if (!Number.isFinite(percent)) return null;
|
|
360
|
-
return Math.max(0, Math.min(100, percent));
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
// ── 메인 ────────────────────────────────────────────────────
|
|
364
|
-
async function main() {
|
|
365
|
-
// CLI: --record-route <slug> <mode> <outcome>
|
|
366
|
-
const rrIdx = process.argv.indexOf("--record-route");
|
|
367
|
-
if (rrIdx !== -1) {
|
|
368
|
-
const slug = process.argv[rrIdx + 1];
|
|
369
|
-
const mode = process.argv[rrIdx + 2];
|
|
370
|
-
const outcome = process.argv[rrIdx + 3];
|
|
371
|
-
if (slug && mode && outcome) recordRouteOutcome(slug, mode, outcome);
|
|
372
|
-
process.exit(0);
|
|
373
|
-
}
|
|
374
|
-
|
|
375
|
-
const stdinRaw = readStdin();
|
|
376
|
-
const registry = loadRegistry();
|
|
377
|
-
|
|
378
|
-
if (!registry) {
|
|
379
|
-
// 레지스트리 없으면 패스스루
|
|
380
|
-
if (stdinRaw.trim()) process.stdout.write(stdinRaw);
|
|
381
|
-
process.exit(0);
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
// stdin에서 이벤트명, 툴명 추출
|
|
385
|
-
let eventName = "";
|
|
386
|
-
let toolName = "";
|
|
387
|
-
if (stdinRaw.trim()) {
|
|
388
|
-
try {
|
|
389
|
-
const input = JSON.parse(stdinRaw);
|
|
390
|
-
eventName = input.hook_event_name || "";
|
|
391
|
-
toolName = input.tool_name || "";
|
|
392
|
-
} catch {
|
|
393
|
-
// 파싱 실패 시 그냥 통과
|
|
394
|
-
process.exit(0);
|
|
395
|
-
}
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
if (!eventName) process.exit(0);
|
|
399
|
-
|
|
400
|
-
const dedupePreToolUseBash = shouldDedupePreToolUseBash(eventName, toolName);
|
|
401
|
-
if (dedupePreToolUseBash) {
|
|
402
|
-
const cached = readHookCache(stdinRaw);
|
|
403
|
-
if (cached?.stderr) process.stderr.write(cached.stderr);
|
|
404
|
-
if (cached?.blocked) process.exit(2);
|
|
405
|
-
if (cached?.mergedOutput) {
|
|
406
|
-
process.stdout.write(JSON.stringify(cached.mergedOutput));
|
|
407
|
-
}
|
|
408
|
-
if (cached) process.exit(0);
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
// ── SessionStart fast-path ──
|
|
412
|
-
// TRIFLUX_HOOK_FAST_PATH=false로 비활성화 가능 (rollback)
|
|
413
|
-
if (
|
|
414
|
-
eventName === "SessionStart" &&
|
|
415
|
-
process.env.TRIFLUX_HOOK_FAST_PATH !== "false"
|
|
416
|
-
) {
|
|
417
|
-
try {
|
|
418
|
-
const { execute } = await import("./session-start-fast.mjs");
|
|
419
|
-
const result = await execute(stdinRaw);
|
|
420
|
-
if (result.stdout?.trim()) {
|
|
421
|
-
const output = { additionalContext: result.stdout.trim() };
|
|
422
|
-
process.stdout.write(JSON.stringify(output));
|
|
423
|
-
}
|
|
424
|
-
|
|
425
|
-
// external source 훅 (session-vault 등)은 기존 방식으로 실행
|
|
426
|
-
const allHooks = registry.events.SessionStart || [];
|
|
427
|
-
const externalHooks = allHooks.filter(
|
|
428
|
-
(h) => h.enabled !== false && h.source !== "triflux",
|
|
429
|
-
);
|
|
430
|
-
for (const hook of externalHooks) {
|
|
431
|
-
const hookResult = executeHookAsync(hook, stdinRaw);
|
|
432
|
-
hookResult.catch(() => {}); // fire-and-forget for external hooks
|
|
433
|
-
}
|
|
434
|
-
|
|
435
|
-
process.exit(0);
|
|
436
|
-
} catch (err) {
|
|
437
|
-
// fast-path 실패 시 기존 방식으로 폴백
|
|
438
|
-
process.stderr.write(
|
|
439
|
-
`[orchestrator] fast-path failed, falling back: ${err.message}\n`,
|
|
440
|
-
);
|
|
441
|
-
}
|
|
442
|
-
}
|
|
443
|
-
|
|
444
|
-
// ── UserPromptSubmit: interactive 세션 liveness heartbeat ──
|
|
445
|
-
// SessionStart 가 register, 매 프롬프트가 갱신한다(사용자 활동 = liveness). 이
|
|
446
|
-
// 갱신이 없으면 hub monitor 가 5분 TTL 후 세션을 stale 로 전이시켜 `cto status`
|
|
447
|
-
// 의 live_sessions 가 항상 비어 보인다. fire-and-forget POST 는 process.exit 가
|
|
448
|
-
// 버리므로(cf. [[project_process_exit_drops_fire_and_forget]]) 발사 직후 바로
|
|
449
|
-
// drain 한다 — 아래 registry hook 이 없어 early-exit 하는 경로에서도 flush 를
|
|
450
|
-
// 보장하기 위해서다. drain 은 localhost POST 라 보통 즉시 반환되고, hub stall
|
|
451
|
-
// 시에만 짧은 상한(500ms)으로 프롬프트 지연을 막는다. keyword-detector /
|
|
452
|
-
// north-star 등 registry hook 은 그 뒤로 평소대로 실행된다.
|
|
453
|
-
if (eventName === "UserPromptSubmit") {
|
|
454
|
-
try {
|
|
455
|
-
const { heartbeatInteractiveSession } = await import(
|
|
456
|
-
"./session-start-fast.mjs"
|
|
457
|
-
);
|
|
458
|
-
heartbeatInteractiveSession(stdinRaw);
|
|
459
|
-
const { drainPendingSynapse } = await import(
|
|
460
|
-
"../hub/team/synapse-http.mjs"
|
|
461
|
-
);
|
|
462
|
-
await drainPendingSynapse(500);
|
|
463
|
-
} catch {
|
|
464
|
-
/* best-effort — heartbeat 실패가 프롬프트 턴을 막지 않는다 */
|
|
465
|
-
}
|
|
466
|
-
}
|
|
467
|
-
|
|
468
|
-
// ── Stop: interactive 세션 liveness heartbeat (턴 종료 시) ──
|
|
469
|
-
// UserPromptSubmit 은 턴 *시작* 에, Stop 은 매 어시스턴트 턴 *종료* 에 heartbeat
|
|
470
|
-
// 한다. 둘이 함께 "대화 중이지만 매 분 새 프롬프트를 보내지는 않는" 세션을 5분
|
|
471
|
-
// interactive TTL 위로 유지해, 대화 도중 stale 로 떨어져 `cto status`
|
|
472
|
-
// live_sessions 에서 사라지는 것을 막는다. 진짜 완전 idle(턴 자체가 없음)
|
|
473
|
-
// 은 여전히 TTL 후 stale 로 가며 — 그게 의도된 dead-session 신호다. UserPromptSubmit
|
|
474
|
-
// 과 동일하게 fire-and-forget POST 직후 짧은 상한으로 drain 한다.
|
|
475
|
-
if (eventName === "Stop") {
|
|
476
|
-
try {
|
|
477
|
-
const { heartbeatInteractiveSession } = await import(
|
|
478
|
-
"./session-start-fast.mjs"
|
|
479
|
-
);
|
|
480
|
-
heartbeatInteractiveSession(stdinRaw);
|
|
481
|
-
const { drainPendingSynapse } = await import(
|
|
482
|
-
"../hub/team/synapse-http.mjs"
|
|
483
|
-
);
|
|
484
|
-
await drainPendingSynapse(500);
|
|
485
|
-
} catch {
|
|
486
|
-
/* best-effort — heartbeat 실패가 Stop 을 막지 않는다 */
|
|
487
|
-
}
|
|
488
|
-
}
|
|
489
|
-
|
|
490
|
-
// 이벤트에 해당하는 훅 목록
|
|
491
|
-
const hooks = registry.events[eventName];
|
|
492
|
-
if (!hooks || hooks.length === 0) process.exit(0);
|
|
493
|
-
|
|
494
|
-
// 우선순위 정렬 (낮을수록 먼저)
|
|
495
|
-
const sorted = [...hooks]
|
|
496
|
-
.filter((h) => h.enabled !== false)
|
|
497
|
-
.sort((a, b) => (a.priority ?? 999) - (b.priority ?? 999));
|
|
498
|
-
|
|
499
|
-
// 매처 필터링
|
|
500
|
-
const matched = sorted.filter((h) => matchesMatcher(h.matcher, toolName));
|
|
501
|
-
|
|
502
|
-
// 같은 priority 그룹별 병렬 실행
|
|
503
|
-
const groups = [];
|
|
504
|
-
for (const hook of matched) {
|
|
505
|
-
const p = hook.priority ?? 999;
|
|
506
|
-
if (groups.length === 0 || groups[groups.length - 1].priority !== p) {
|
|
507
|
-
groups.push({ priority: p, hooks: [hook] });
|
|
508
|
-
} else {
|
|
509
|
-
groups[groups.length - 1].hooks.push(hook);
|
|
510
|
-
}
|
|
511
|
-
}
|
|
512
|
-
|
|
513
|
-
let mergedOutput = null;
|
|
514
|
-
let blocked = false;
|
|
515
|
-
let blockingStderr = "";
|
|
516
|
-
|
|
517
|
-
if (dedupePreToolUseBash) {
|
|
518
|
-
for (const hook of matched) {
|
|
519
|
-
const result = executeHook(hook, stdinRaw);
|
|
520
|
-
if (result.code === 2) {
|
|
521
|
-
blockingStderr = result.stderr || "";
|
|
522
|
-
if (blockingStderr) process.stderr.write(blockingStderr);
|
|
523
|
-
blocked = true;
|
|
524
|
-
break;
|
|
525
|
-
}
|
|
526
|
-
if (result.code === 0 && result.stdout.trim()) {
|
|
527
|
-
mergedOutput = mergeOutputs(mergedOutput, result.stdout.trim());
|
|
528
|
-
}
|
|
529
|
-
if (shouldShortCircuitPreToolUseBash(result)) {
|
|
530
|
-
break;
|
|
531
|
-
}
|
|
532
|
-
}
|
|
533
|
-
} else {
|
|
534
|
-
for (const group of groups) {
|
|
535
|
-
if (blocked) break;
|
|
536
|
-
|
|
537
|
-
if (group.hooks.length === 1) {
|
|
538
|
-
// 단일 훅 — 기존 동기 실행
|
|
539
|
-
const result = executeHook(group.hooks[0], stdinRaw);
|
|
540
|
-
if (result.code === 2) {
|
|
541
|
-
blockingStderr = result.stderr || "";
|
|
542
|
-
if (blockingStderr) process.stderr.write(blockingStderr);
|
|
543
|
-
blocked = true;
|
|
544
|
-
break;
|
|
545
|
-
}
|
|
546
|
-
if (result.code === 0 && result.stdout.trim()) {
|
|
547
|
-
mergedOutput = mergeOutputs(mergedOutput, result.stdout.trim());
|
|
548
|
-
}
|
|
549
|
-
} else {
|
|
550
|
-
// 같은 priority 다중 훅 — 비동기 병렬 실행
|
|
551
|
-
const results = await Promise.all(
|
|
552
|
-
group.hooks.map((h) => executeHookAsync(h, stdinRaw)),
|
|
553
|
-
);
|
|
554
|
-
for (const result of results) {
|
|
555
|
-
if (result.code === 2) {
|
|
556
|
-
blockingStderr = result.stderr || "";
|
|
557
|
-
if (blockingStderr) process.stderr.write(blockingStderr);
|
|
558
|
-
blocked = true;
|
|
559
|
-
break;
|
|
560
|
-
}
|
|
561
|
-
if (result.code === 0 && result.stdout.trim()) {
|
|
562
|
-
mergedOutput = mergeOutputs(mergedOutput, result.stdout.trim());
|
|
563
|
-
}
|
|
564
|
-
}
|
|
565
|
-
}
|
|
566
|
-
}
|
|
567
|
-
}
|
|
568
|
-
|
|
569
|
-
// ── PostToolUse: 컨텍스트 압축 nudge (인라인, 프로세스 추가 없음) ──
|
|
570
|
-
if (eventName === "PostToolUse" && toolName !== "Bash" && !blocked) {
|
|
571
|
-
try {
|
|
572
|
-
const home = process.env.HOME || process.env.USERPROFILE || "";
|
|
573
|
-
const snapshotPath = join(
|
|
574
|
-
home,
|
|
575
|
-
".claude",
|
|
576
|
-
"cache",
|
|
577
|
-
"tfx-hub",
|
|
578
|
-
"context-monitor.json",
|
|
579
|
-
);
|
|
580
|
-
const nudgeMarker = join(tmpdir(), "tfx-compact-nudge-sent");
|
|
581
|
-
if (existsSync(snapshotPath) && !existsSync(nudgeMarker)) {
|
|
582
|
-
const snap = JSON.parse(readFileSync(snapshotPath, "utf8"));
|
|
583
|
-
const percent = readCompactNudgePercent(snap);
|
|
584
|
-
if (percent != null && percent >= 80) {
|
|
585
|
-
const level = percent >= 90 ? "critical" : "warn";
|
|
586
|
-
const msg =
|
|
587
|
-
level === "critical"
|
|
588
|
-
? `[context ${percent}%] 컨텍스트 ${percent}% 사용. /compact 또는 에이전트 분할을 강력 권장합니다.`
|
|
589
|
-
: `[context ${percent}%] 컨텍스트 ${percent}% 사용. 마일스톤이면 /compact를 권장합니다.`;
|
|
590
|
-
mergedOutput = mergeOutputs(
|
|
591
|
-
mergedOutput,
|
|
592
|
-
JSON.stringify({ systemMessage: msg }),
|
|
593
|
-
);
|
|
594
|
-
if (level === "warn") {
|
|
595
|
-
writeFileSync(nudgeMarker, new Date().toISOString());
|
|
596
|
-
}
|
|
597
|
-
}
|
|
598
|
-
}
|
|
599
|
-
} catch {
|
|
600
|
-
/* 컨텍스트 모니터 읽기 실패 무시 */
|
|
601
|
-
}
|
|
602
|
-
}
|
|
603
|
-
|
|
604
|
-
// ── PostToolUse:Skill 완료 시 라우팅 가중치 기록 ──
|
|
605
|
-
if (eventName === "PostToolUse" && toolName === "Skill" && !blocked) {
|
|
606
|
-
try {
|
|
607
|
-
const input = JSON.parse(stdinRaw);
|
|
608
|
-
const skillName = input.tool_input?.skill || "";
|
|
609
|
-
if (skillName?.startsWith("tfx-")) {
|
|
610
|
-
const mode = skillName.replace(/^tfx-/, "");
|
|
611
|
-
const gitRoot = process.env.GIT_WORK_TREE || process.cwd();
|
|
612
|
-
const slug = gitRoot.split(/[\\/]/).pop() || "unknown";
|
|
613
|
-
recordRouteOutcome(slug, mode, "completion");
|
|
614
|
-
}
|
|
615
|
-
} catch {
|
|
616
|
-
/* 가중치 기록 실패 무시 */
|
|
617
|
-
}
|
|
618
|
-
}
|
|
619
|
-
|
|
620
|
-
// 결과 출력
|
|
621
|
-
if (blocked) {
|
|
622
|
-
if (dedupePreToolUseBash) {
|
|
623
|
-
writeHookCache(stdinRaw, {
|
|
624
|
-
blocked: true,
|
|
625
|
-
mergedOutput: null,
|
|
626
|
-
stderr: blockingStderr,
|
|
627
|
-
});
|
|
628
|
-
}
|
|
629
|
-
process.exit(2);
|
|
630
|
-
}
|
|
631
|
-
|
|
632
|
-
if (dedupePreToolUseBash) {
|
|
633
|
-
writeHookCache(stdinRaw, {
|
|
634
|
-
blocked: false,
|
|
635
|
-
mergedOutput,
|
|
636
|
-
stderr: "",
|
|
637
|
-
});
|
|
638
|
-
}
|
|
639
|
-
|
|
640
|
-
if (mergedOutput) {
|
|
641
|
-
process.stdout.write(JSON.stringify(mergedOutput));
|
|
642
|
-
}
|
|
643
|
-
|
|
644
|
-
process.exit(0);
|
|
645
|
-
}
|
|
646
|
-
|
|
647
|
-
main().catch((err) => {
|
|
648
|
-
// 오케스트레이터 자체 실패 → 비차단
|
|
649
|
-
process.stderr.write(`[hook-orchestrator] error: ${err.message}\n`);
|
|
650
|
-
process.exit(0);
|
|
651
|
-
});
|
|
@@ -15,7 +15,7 @@ function normalizeCandidate(candidate) {
|
|
|
15
15
|
function isValidPluginRoot(candidate) {
|
|
16
16
|
const root = normalizeCandidate(candidate);
|
|
17
17
|
if (!root) return false;
|
|
18
|
-
return existsSync(join(root, "hooks", "hook
|
|
18
|
+
return existsSync(join(root, "hooks", "codex-session-hook.mjs"));
|
|
19
19
|
}
|
|
20
20
|
|
|
21
21
|
function toPluginRootFromUrl(url) {
|