@triflux/core 10.1.0 → 10.26.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.
Files changed (145) hide show
  1. package/hooks/agent-route-guard.mjs +14 -10
  2. package/hooks/cross-review-tracker.mjs +21 -8
  3. package/hooks/error-context.mjs +20 -8
  4. package/hooks/hook-adaptive-collector.mjs +18 -16
  5. package/hooks/hook-manager.mjs +93 -32
  6. package/hooks/hook-orchestrator.mjs +267 -35
  7. package/hooks/hook-registry.json +343 -256
  8. package/hooks/hooks.json +49 -1
  9. package/hooks/keyword-rules.json +191 -31
  10. package/hooks/lib/resolve-root.mjs +1 -1
  11. package/hooks/mcp-config-watcher.mjs +6 -2
  12. package/hooks/permission-safe-allow.mjs +233 -0
  13. package/hooks/pipeline-stop.mjs +369 -22
  14. package/hooks/post-tool-tips.mjs +306 -0
  15. package/hooks/pre-compact-snapshot.mjs +181 -0
  16. package/hooks/safety-guard.mjs +262 -34
  17. package/hooks/session-end-cleanup.mjs +345 -0
  18. package/hooks/session-start-fast.mjs +214 -0
  19. package/hooks/subagent-tracker.mjs +267 -0
  20. package/hooks/subagent-verifier.mjs +9 -4
  21. package/hub/account-broker.mjs +1036 -66
  22. package/hub/adaptive-diagnostic.mjs +75 -48
  23. package/hub/adaptive-inject.mjs +96 -58
  24. package/hub/adaptive-memory.mjs +156 -42
  25. package/hub/adaptive.mjs +60 -31
  26. package/hub/assign-callbacks.mjs +163 -126
  27. package/hub/bridge.mjs +1051 -290
  28. package/hub/cli-adapter-base.mjs +321 -97
  29. package/hub/codex-adapter.mjs +179 -93
  30. package/hub/codex-compat.mjs +4 -4
  31. package/hub/codex-preflight.mjs +113 -37
  32. package/hub/delegator/contracts.mjs +22 -21
  33. package/hub/delegator/index.mjs +14 -14
  34. package/hub/delegator/schema/delegator-tools.schema.json +1 -1
  35. package/hub/delegator/service.mjs +93 -64
  36. package/hub/delegator/tool-definitions.mjs +35 -35
  37. package/hub/dynamic-routing-engine.mjs +511 -0
  38. package/hub/fullcycle.mjs +34 -18
  39. package/hub/gemini-adapter.mjs +82 -108
  40. package/hub/hitl.mjs +202 -143
  41. package/hub/intent.mjs +167 -38
  42. package/hub/lib/bash-path.mjs +73 -0
  43. package/hub/lib/cache-guard.mjs +43 -17
  44. package/hub/lib/env-detect.mjs +145 -0
  45. package/hub/lib/hosts-compat.mjs +319 -0
  46. package/hub/lib/mcp-response-cache.mjs +239 -0
  47. package/hub/lib/memory-store.mjs +285 -111
  48. package/hub/lib/path-utils.mjs +165 -0
  49. package/hub/lib/phase-manager.mjs +493 -0
  50. package/hub/lib/process-utils.mjs +797 -104
  51. package/hub/lib/prompt-tmp.mjs +33 -0
  52. package/hub/lib/risk-tier.mjs +53 -0
  53. package/hub/lib/spawn-trace.mjs +561 -0
  54. package/hub/lib/ssh-command.mjs +44 -24
  55. package/hub/lib/ssh-retry.mjs +5 -1
  56. package/hub/lib/staged-file-detect.mjs +43 -0
  57. package/hub/lib/state-snapshot.mjs +293 -0
  58. package/hub/lib/tfx-route-args.mjs +309 -0
  59. package/hub/lib/timeline-adapter.mjs +190 -0
  60. package/hub/lib/trace-recorder.mjs +153 -0
  61. package/hub/lib/uuidv7.mjs +4 -3
  62. package/hub/memory-doctor.mjs +266 -106
  63. package/hub/middleware/quota-middleware.mjs +70 -0
  64. package/hub/middleware/request-logger.mjs +61 -34
  65. package/hub/paths.mjs +9 -9
  66. package/hub/pipeline/gates/confidence.mjs +34 -15
  67. package/hub/pipeline/gates/consensus.mjs +27 -15
  68. package/hub/pipeline/gates/index.mjs +7 -3
  69. package/hub/pipeline/gates/selfcheck.mjs +57 -19
  70. package/hub/pipeline/index.mjs +77 -42
  71. package/hub/pipeline/state.mjs +10 -10
  72. package/hub/pipeline/transitions.mjs +40 -23
  73. package/hub/platform.mjs +65 -48
  74. package/hub/quality/deslop.mjs +70 -49
  75. package/hub/reflexion.mjs +87 -13
  76. package/hub/research.mjs +33 -25
  77. package/hub/router.mjs +941 -791
  78. package/hub/routing/complexity.mjs +132 -29
  79. package/hub/routing/index.mjs +17 -12
  80. package/hub/routing/q-learning.mjs +78 -30
  81. package/hub/routing-snapshot.mjs +261 -0
  82. package/hub/session-fingerprint.mjs +127 -61
  83. package/hub/state.mjs +86 -44
  84. package/hub/team/claude-daemon-control.mjs +1130 -0
  85. package/hub/team/retry-state-machine.mjs +336 -0
  86. package/hub/token-mode.mjs +72 -71
  87. package/hub/workers/worker-utils.mjs +23 -17
  88. package/hud/colors.mjs +27 -9
  89. package/hud/constants.mjs +190 -27
  90. package/hud/context-monitor.mjs +133 -44
  91. package/hud/hud-qos-status.mjs +157 -50
  92. package/hud/mission-board.mjs +56 -0
  93. package/hud/providers/claude.mjs +398 -114
  94. package/hud/providers/codex.mjs +96 -21
  95. package/hud/providers/gemini.mjs +210 -52
  96. package/hud/renderers.mjs +440 -127
  97. package/hud/terminal.mjs +101 -31
  98. package/hud/utils.mjs +81 -39
  99. package/mesh/index.mjs +69 -0
  100. package/mesh/mesh-budget.mjs +137 -0
  101. package/mesh/mesh-heartbeat.mjs +100 -0
  102. package/mesh/mesh-protocol.mjs +96 -0
  103. package/mesh/mesh-queue.mjs +163 -0
  104. package/mesh/mesh-registry.mjs +78 -0
  105. package/mesh/mesh-router.mjs +77 -0
  106. package/package.json +5 -1
  107. package/scripts/lib/agent-json.mjs +27 -0
  108. package/scripts/lib/async.mjs +174 -0
  109. package/scripts/lib/claudemd-scanner.mjs +6 -1
  110. package/scripts/lib/cli-agy.mjs +62 -0
  111. package/scripts/lib/cli-claude.mjs +78 -0
  112. package/scripts/lib/cli-codex.mjs +199 -0
  113. package/scripts/lib/cli-gemini.mjs +37 -0
  114. package/scripts/lib/codex-recovery.sh +50 -0
  115. package/scripts/lib/context.mjs +3 -3
  116. package/scripts/lib/cross-review-utils.mjs +13 -5
  117. package/scripts/lib/doctor-env-checks.mjs +121 -0
  118. package/scripts/lib/dynamic-route-cli.mjs +107 -0
  119. package/scripts/lib/env-probe.mjs +58 -30
  120. package/scripts/lib/env.mjs +14 -0
  121. package/scripts/lib/gemini-profiles.mjs +44 -10
  122. package/scripts/lib/handoff.mjs +33 -17
  123. package/scripts/lib/hook-utils.mjs +8 -6
  124. package/scripts/lib/hub.mjs +48 -0
  125. package/scripts/lib/keyword-rules.mjs +49 -20
  126. package/scripts/lib/logger.mjs +24 -24
  127. package/scripts/lib/mcp-filter.mjs +391 -247
  128. package/scripts/lib/mcp-gateway-health-check.mjs +159 -0
  129. package/scripts/lib/mcp-gateway-servers.mjs +70 -0
  130. package/scripts/lib/mcp-gateway-wrapper-check.mjs +35 -0
  131. package/scripts/lib/mcp-guard-engine.mjs +1281 -115
  132. package/scripts/lib/mcp-health.mjs +773 -0
  133. package/scripts/lib/mcp-manifest.mjs +24 -14
  134. package/scripts/lib/mcp-server-catalog.mjs +355 -118
  135. package/scripts/lib/pid.mjs +63 -0
  136. package/scripts/lib/process-utils.mjs +26 -0
  137. package/scripts/lib/psmux-info.mjs +117 -12
  138. package/scripts/lib/quota.mjs +47 -0
  139. package/scripts/lib/remote-spawn-transfer.mjs +44 -14
  140. package/scripts/lib/skill-state.mjs +220 -0
  141. package/scripts/lib/skill-template.mjs +32 -7
  142. package/scripts/lib/team.mjs +75 -0
  143. package/scripts/lib/timeout.mjs +66 -0
  144. package/scripts/lib/tmp.mjs +26 -0
  145. package/scripts/lib/toml.mjs +113 -0
@@ -1,4 +1,5 @@
1
1
  #!/usr/bin/env node
2
+
2
3
  // hooks/hook-orchestrator.mjs — 범용 훅 체이닝 엔진
3
4
  //
4
5
  // settings.json에 이벤트당 하나만 등록. stdin JSON에서 이벤트명+툴명을 읽고
@@ -19,15 +20,30 @@
19
20
  // CLAUDE_PLUGIN_ROOT — ${PLUGIN_ROOT} 치환용
20
21
  // HOME / USERPROFILE — ${HOME} 치환용
21
22
 
22
- import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
23
- import { join, dirname } from "node:path";
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";
24
34
  import { fileURLToPath } from "node:url";
25
- import { execFileSync, execFile } from "node:child_process";
26
35
  import { PLUGIN_ROOT } from "./lib/resolve-root.mjs";
27
36
 
28
37
  const __dirname = dirname(fileURLToPath(import.meta.url));
29
38
  const REGISTRY_PATH =
30
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
+ );
31
47
 
32
48
  // ── stdin 읽기 ──────────────────────────────────────────────
33
49
  function readStdin() {
@@ -75,6 +91,57 @@ function matchesMatcher(hookMatcher, toolName, eventInput) {
75
91
  });
76
92
  }
77
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
+
78
145
  // ── 단일 훅 실행 ────────────────────────────────────────────
79
146
  function executeHook(hook, stdinData) {
80
147
  const cmd = resolveCommand(hook.command);
@@ -84,7 +151,8 @@ function executeHook(hook, stdinData) {
84
151
  // "bash script.sh" → ["bash", ["script.sh"]]
85
152
  // 따옴표 처리 포함
86
153
  const parts = parseCommand(cmd);
87
- if (parts.length === 0) return { code: 1, stdout: "", stderr: "empty command" };
154
+ if (parts.length === 0)
155
+ return { code: 1, stdout: "", stderr: "empty command" };
88
156
 
89
157
  const [executable, ...args] = parts;
90
158
 
@@ -114,20 +182,35 @@ function executeHookAsync(hook, stdinData) {
114
182
  const cmd = resolveCommand(hook.command);
115
183
  const timeout = (hook.timeout || 10) * 1000;
116
184
  const parts = parseCommand(cmd);
117
- if (parts.length === 0) { resolve({ code: 1, stdout: "", stderr: "empty command" }); return; }
185
+ if (parts.length === 0) {
186
+ resolve({ code: 1, stdout: "", stderr: "empty command" });
187
+ return;
188
+ }
118
189
  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();
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();
131
214
  });
132
215
  }
133
216
 
@@ -170,11 +253,19 @@ function mergeOutputs(accumulated, newOutput) {
170
253
 
171
254
  // hookSpecificOutput 머지 — additionalContext는 누적, 나머지는 덮어쓰기
172
255
  if (parsed.hookSpecificOutput) {
173
- if (accumulated.hookSpecificOutput?.additionalContext && parsed.hookSpecificOutput.additionalContext) {
256
+ if (
257
+ accumulated.hookSpecificOutput?.additionalContext &&
258
+ parsed.hookSpecificOutput.additionalContext
259
+ ) {
174
260
  parsed.hookSpecificOutput.additionalContext =
175
- accumulated.hookSpecificOutput.additionalContext + "\n" + parsed.hookSpecificOutput.additionalContext;
261
+ accumulated.hookSpecificOutput.additionalContext +
262
+ "\n" +
263
+ parsed.hookSpecificOutput.additionalContext;
176
264
  }
177
- accumulated.hookSpecificOutput = { ...accumulated.hookSpecificOutput, ...parsed.hookSpecificOutput };
265
+ accumulated.hookSpecificOutput = {
266
+ ...accumulated.hookSpecificOutput,
267
+ ...parsed.hookSpecificOutput,
268
+ };
178
269
  }
179
270
  // systemMessage는 누적
180
271
  if (parsed.systemMessage) {
@@ -220,7 +311,12 @@ function recordRouteOutcome(slug, mode, outcome) {
220
311
 
221
312
  const weights = existsSync(weightsPath)
222
313
  ? JSON.parse(readFileSync(weightsPath, "utf8"))
223
- : { updated_at: null, total_routes: 0, overrides: 0, weights: { mode_bias: {}, profile_bias: {}, depth_bias: {} } };
314
+ : {
315
+ updated_at: null,
316
+ total_routes: 0,
317
+ overrides: 0,
318
+ weights: { mode_bias: {}, profile_bias: {}, depth_bias: {} },
319
+ };
224
320
 
225
321
  weights.total_routes++;
226
322
  weights.updated_at = new Date().toISOString();
@@ -247,6 +343,23 @@ function recordRouteOutcome(slug, mode, outcome) {
247
343
  writeFileSync(weightsPath, JSON.stringify(weights, null, 2), "utf8");
248
344
  }
249
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
+
250
363
  // ── 메인 ────────────────────────────────────────────────────
251
364
  async function main() {
252
365
  // CLI: --record-route <slug> <mode> <outcome>
@@ -284,6 +397,50 @@ async function main() {
284
397
 
285
398
  if (!eventName) process.exit(0);
286
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
+
287
444
  // 이벤트에 해당하는 훅 목록
288
445
  const hooks = registry.events[eventName];
289
446
  if (!hooks || hooks.length === 0) process.exit(0);
@@ -309,34 +466,92 @@ async function main() {
309
466
 
310
467
  let mergedOutput = null;
311
468
  let blocked = false;
469
+ let blockingStderr = "";
312
470
 
313
- for (const group of groups) {
314
- if (blocked) break;
315
-
316
- if (group.hooks.length === 1) {
317
- // 단일 훅 — 기존 동기 실행
318
- const result = executeHook(group.hooks[0], stdinRaw);
471
+ if (dedupePreToolUseBash) {
472
+ for (const hook of matched) {
473
+ const result = executeHook(hook, stdinRaw);
319
474
  if (result.code === 2) {
320
- if (result.stderr) process.stderr.write(result.stderr);
475
+ blockingStderr = result.stderr || "";
476
+ if (blockingStderr) process.stderr.write(blockingStderr);
321
477
  blocked = true;
322
478
  break;
323
479
  }
324
480
  if (result.code === 0 && result.stdout.trim()) {
325
481
  mergedOutput = mergeOutputs(mergedOutput, result.stdout.trim());
326
482
  }
327
- } else {
328
- // 같은 priority 다중 훅 — 비동기 병렬 실행
329
- const results = await Promise.all(group.hooks.map((h) => executeHookAsync(h, stdinRaw)));
330
- for (const result of results) {
483
+ if (shouldShortCircuitPreToolUseBash(result)) {
484
+ break;
485
+ }
486
+ }
487
+ } else {
488
+ for (const group of groups) {
489
+ if (blocked) break;
490
+
491
+ if (group.hooks.length === 1) {
492
+ // 단일 훅 — 기존 동기 실행
493
+ const result = executeHook(group.hooks[0], stdinRaw);
331
494
  if (result.code === 2) {
332
- if (result.stderr) process.stderr.write(result.stderr);
495
+ blockingStderr = result.stderr || "";
496
+ if (blockingStderr) process.stderr.write(blockingStderr);
333
497
  blocked = true;
334
498
  break;
335
499
  }
336
500
  if (result.code === 0 && result.stdout.trim()) {
337
501
  mergedOutput = mergeOutputs(mergedOutput, result.stdout.trim());
338
502
  }
503
+ } else {
504
+ // 같은 priority 다중 훅 — 비동기 병렬 실행
505
+ const results = await Promise.all(
506
+ group.hooks.map((h) => executeHookAsync(h, stdinRaw)),
507
+ );
508
+ for (const result of results) {
509
+ if (result.code === 2) {
510
+ blockingStderr = result.stderr || "";
511
+ if (blockingStderr) process.stderr.write(blockingStderr);
512
+ blocked = true;
513
+ break;
514
+ }
515
+ if (result.code === 0 && result.stdout.trim()) {
516
+ mergedOutput = mergeOutputs(mergedOutput, result.stdout.trim());
517
+ }
518
+ }
519
+ }
520
+ }
521
+ }
522
+
523
+ // ── PostToolUse: 컨텍스트 압축 nudge (인라인, 프로세스 추가 없음) ──
524
+ if (eventName === "PostToolUse" && toolName !== "Bash" && !blocked) {
525
+ try {
526
+ const home = process.env.HOME || process.env.USERPROFILE || "";
527
+ const snapshotPath = join(
528
+ home,
529
+ ".claude",
530
+ "cache",
531
+ "tfx-hub",
532
+ "context-monitor.json",
533
+ );
534
+ const nudgeMarker = join(tmpdir(), "tfx-compact-nudge-sent");
535
+ if (existsSync(snapshotPath) && !existsSync(nudgeMarker)) {
536
+ const snap = JSON.parse(readFileSync(snapshotPath, "utf8"));
537
+ const percent = readCompactNudgePercent(snap);
538
+ if (percent != null && percent >= 80) {
539
+ const level = percent >= 90 ? "critical" : "warn";
540
+ const msg =
541
+ level === "critical"
542
+ ? `[context ${percent}%] 컨텍스트 ${percent}% 사용. /compact 또는 에이전트 분할을 강력 권장합니다.`
543
+ : `[context ${percent}%] 컨텍스트 ${percent}% 사용. 마일스톤이면 /compact를 권장합니다.`;
544
+ mergedOutput = mergeOutputs(
545
+ mergedOutput,
546
+ JSON.stringify({ systemMessage: msg }),
547
+ );
548
+ if (level === "warn") {
549
+ writeFileSync(nudgeMarker, new Date().toISOString());
550
+ }
551
+ }
339
552
  }
553
+ } catch {
554
+ /* 컨텍스트 모니터 읽기 실패 무시 */
340
555
  }
341
556
  }
342
557
 
@@ -345,20 +560,37 @@ async function main() {
345
560
  try {
346
561
  const input = JSON.parse(stdinRaw);
347
562
  const skillName = input.tool_input?.skill || "";
348
- if (skillName && skillName.startsWith("tfx-")) {
563
+ if (skillName?.startsWith("tfx-")) {
349
564
  const mode = skillName.replace(/^tfx-/, "");
350
565
  const gitRoot = process.env.GIT_WORK_TREE || process.cwd();
351
566
  const slug = gitRoot.split(/[\\/]/).pop() || "unknown";
352
567
  recordRouteOutcome(slug, mode, "completion");
353
568
  }
354
- } catch { /* 가중치 기록 실패 무시 */ }
569
+ } catch {
570
+ /* 가중치 기록 실패 무시 */
571
+ }
355
572
  }
356
573
 
357
574
  // 결과 출력
358
575
  if (blocked) {
576
+ if (dedupePreToolUseBash) {
577
+ writeHookCache(stdinRaw, {
578
+ blocked: true,
579
+ mergedOutput: null,
580
+ stderr: blockingStderr,
581
+ });
582
+ }
359
583
  process.exit(2);
360
584
  }
361
585
 
586
+ if (dedupePreToolUseBash) {
587
+ writeHookCache(stdinRaw, {
588
+ blocked: false,
589
+ mergedOutput,
590
+ stderr: "",
591
+ });
592
+ }
593
+
362
594
  if (mergedOutput) {
363
595
  process.stdout.write(JSON.stringify(mergedOutput));
364
596
  }