triflux 9.7.0 → 9.7.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/bin/triflux.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  // triflux CLI — setup, doctor, version
3
3
  import { copyFileSync, existsSync, readFileSync, readSync, writeFileSync, mkdirSync, chmodSync, readdirSync, unlinkSync, statSync, openSync, closeSync } from "fs";
4
- import { join, dirname } from "path";
4
+ import { join, dirname, basename } from "path";
5
5
  import { homedir } from "os";
6
6
  import { execSync, execFileSync, spawn } from "child_process";
7
7
  import { fileURLToPath } from "url";
@@ -24,6 +24,7 @@ import {
24
24
  SYNC_MAP, SKILL_ALIASES, REQUIRED_CODEX_PROFILES, LEGACY_CODEX_MODELS,
25
25
  syncAliasedSkillDir, hasProfileSection, replaceProfileSection,
26
26
  ensureCodexProfiles, getVersion, cleanupStaleSkills, DEPRECATED_SKILLS,
27
+ extractManagedHookFilename, getManagedRegistryHooks, ensureHooksInSettings,
27
28
  } from "../scripts/setup.mjs";
28
29
 
29
30
  const PKG_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
@@ -941,6 +942,37 @@ function addDoctorCheck(report, entry) {
941
942
  report.checks.push(entry);
942
943
  }
943
944
 
945
+ function toHookCoverageName(fileName, fallbackId = "") {
946
+ if (typeof fileName === "string" && fileName.trim()) {
947
+ return basename(fileName).replace(/\.mjs$/i, "");
948
+ }
949
+ return String(fallbackId || "").replace(/^tfx-/, "");
950
+ }
951
+
952
+ function computeHookCoverage(settings, managedHooks) {
953
+ const coverage = {
954
+ total: managedHooks.length,
955
+ registered: 0,
956
+ missing: [],
957
+ };
958
+
959
+ const hooksByEvent = settings?.hooks && typeof settings.hooks === "object" ? settings.hooks : {};
960
+ for (const spec of managedHooks) {
961
+ const eventEntries = Array.isArray(hooksByEvent[spec.event]) ? hooksByEvent[spec.event] : [];
962
+ const found = eventEntries.some((entry) =>
963
+ Array.isArray(entry?.hooks) &&
964
+ entry.hooks.some((hook) => extractManagedHookFilename(hook?.command) === spec.fileName),
965
+ );
966
+ if (found) {
967
+ coverage.registered++;
968
+ continue;
969
+ }
970
+ coverage.missing.push(toHookCoverageName(spec.fileName, spec.id));
971
+ }
972
+
973
+ return coverage;
974
+ }
975
+
944
976
  function formatPathForDisplay(filePath) {
945
977
  const value = String(filePath || "").replace(/\\/g, "/");
946
978
  const homePath = homedir().replace(/\\/g, "/");
@@ -1046,6 +1078,7 @@ async function cmdDoctor(options = {}) {
1046
1078
  mode: reset ? "reset" : fix ? "fix" : "check",
1047
1079
  checks: [],
1048
1080
  actions: [],
1081
+ hook_coverage: { total: 0, registered: 0, missing: [] },
1049
1082
  issue_count: 0,
1050
1083
  };
1051
1084
 
@@ -2091,6 +2124,93 @@ async function cmdDoctor(options = {}) {
2091
2124
  }
2092
2125
  }
2093
2126
 
2127
+ // ── Hook Coverage (hook-registry vs settings.json) ──
2128
+ section("Hook Coverage");
2129
+ {
2130
+ const registryPath = join(PKG_ROOT, "hooks", "hook-registry.json");
2131
+ const settingsPath = join(CLAUDE_DIR, "settings.json");
2132
+ const managedHooks = getManagedRegistryHooks(registryPath);
2133
+
2134
+ if (managedHooks.length === 0) {
2135
+ addDoctorCheck(report, {
2136
+ name: "hook-coverage",
2137
+ status: "invalid",
2138
+ total: 0,
2139
+ registered: 0,
2140
+ missing: [],
2141
+ fix: "hook-registry.json을 확인하세요.",
2142
+ });
2143
+ warn("hook-registry.json에서 관리 대상 훅을 찾지 못했습니다.");
2144
+ issues++;
2145
+ } else {
2146
+ let settings = {};
2147
+ if (existsSync(settingsPath)) {
2148
+ try {
2149
+ settings = JSON.parse(readFileSync(settingsPath, "utf8"));
2150
+ } catch (error) {
2151
+ const unreadableCoverage = {
2152
+ total: managedHooks.length,
2153
+ registered: 0,
2154
+ missing: managedHooks.map((spec) => toHookCoverageName(spec.fileName, spec.id)),
2155
+ };
2156
+ report.hook_coverage = unreadableCoverage;
2157
+ addDoctorCheck(report, {
2158
+ name: "hook-coverage",
2159
+ status: "invalid",
2160
+ total: unreadableCoverage.total,
2161
+ registered: unreadableCoverage.registered,
2162
+ missing: unreadableCoverage.missing,
2163
+ fix: "settings.json 문법을 수정하거나 tfx setup을 다시 실행하세요.",
2164
+ });
2165
+ fail(`settings.json 파싱 실패: ${error.message}`);
2166
+ issues++;
2167
+ settings = null;
2168
+ }
2169
+ }
2170
+
2171
+ if (settings) {
2172
+ let coverage = computeHookCoverage(settings, managedHooks);
2173
+
2174
+ if (coverage.missing.length > 0 && fix) {
2175
+ const hookFixResult = ensureHooksInSettings({ settingsPath, registryPath });
2176
+ if (hookFixResult.ok) {
2177
+ if (hookFixResult.changed) {
2178
+ ok(`누락 훅 ${hookFixResult.added.length}개 자동 등록됨`);
2179
+ } else {
2180
+ info("누락 훅 자동 등록: 변경 사항 없음");
2181
+ }
2182
+ try {
2183
+ const fixedSettings = JSON.parse(readFileSync(settingsPath, "utf8"));
2184
+ coverage = computeHookCoverage(fixedSettings, managedHooks);
2185
+ } catch (error) {
2186
+ warn(`자동 등록 후 settings.json 재검증 실패: ${error.message}`);
2187
+ }
2188
+ } else {
2189
+ warn(`누락 훅 자동 등록 실패: ${hookFixResult.reason || "unknown_error"}`);
2190
+ }
2191
+ }
2192
+
2193
+ report.hook_coverage = coverage;
2194
+ const coverageStatus = coverage.missing.length === 0 ? "ok" : "issues";
2195
+ addDoctorCheck(report, {
2196
+ name: "hook-coverage",
2197
+ status: coverageStatus,
2198
+ total: coverage.total,
2199
+ registered: coverage.registered,
2200
+ missing: coverage.missing,
2201
+ ...(coverage.missing.length > 0 ? { fix: "tfx doctor --fix 또는 tfx setup" } : {}),
2202
+ });
2203
+
2204
+ if (coverage.missing.length === 0) {
2205
+ ok(`Hook Coverage: ${coverage.registered}/${coverage.total} registered`);
2206
+ } else {
2207
+ fail(`Missing hooks: ${coverage.missing.join(", ")}`);
2208
+ issues += coverage.missing.length;
2209
+ }
2210
+ }
2211
+ }
2212
+ }
2213
+
2094
2214
  // 결과
2095
2215
  console.log(`\n ${LINE}`);
2096
2216
  if (issues === 0) {
@@ -12,16 +12,16 @@
12
12
  //
13
13
  // Claude 대화에서 AskUserQuestion으로 UI를 제공하며 내부적으로 이 명령들을 호출합니다.
14
14
 
15
- import { readFileSync, writeFileSync, existsSync, copyFileSync } from "node:fs";
15
+ import { readFileSync, writeFileSync, existsSync } from "node:fs";
16
16
  import { join, dirname } from "node:path";
17
17
  import { fileURLToPath } from "node:url";
18
+ import { PLUGIN_ROOT } from "./lib/resolve-root.mjs";
18
19
 
19
20
  const __dirname = dirname(fileURLToPath(import.meta.url));
20
21
  const HOME = process.env.HOME || process.env.USERPROFILE || "";
21
22
  const SETTINGS_PATH = join(HOME, ".claude", "settings.json");
22
23
  const BACKUP_PATH = join(HOME, ".claude", "settings.hooks-backup.json");
23
24
  const REGISTRY_PATH = join(__dirname, "hook-registry.json");
24
- const ORCHESTRATOR_PATH = join(__dirname, "hook-orchestrator.mjs");
25
25
 
26
26
  // ── 유틸리티 ────────────────────────────────────────────────
27
27
 
@@ -105,9 +105,9 @@ function normalizeCmd(cmd) {
105
105
  }
106
106
 
107
107
  function resolveVars(cmd) {
108
- const pluginRoot = process.env.CLAUDE_PLUGIN_ROOT || __dirname.replace(/[\\/]hooks$/, "");
109
108
  return cmd
110
- .replace(/\$\{PLUGIN_ROOT\}/g, pluginRoot)
109
+ .replace(/\$\{PLUGIN_ROOT\}/g, PLUGIN_ROOT)
110
+ .replace(/\$\{CLAUDE_PLUGIN_ROOT\}/g, PLUGIN_ROOT)
111
111
  .replace(/\$\{HOME\}/g, HOME)
112
112
  .replace(/\$HOME\b/g, HOME);
113
113
  }
@@ -171,7 +171,8 @@ function apply() {
171
171
 
172
172
  // 오케스트레이터 명령 생성
173
173
  const nodeExe = getNodeExe();
174
- const orchestratorCmd = `"${nodeExe}" "${ORCHESTRATOR_PATH}"`;
174
+ const orchestratorPath = "${CLAUDE_PLUGIN_ROOT}/hooks/hook-orchestrator.mjs";
175
+ const orchestratorCmd = `"${nodeExe}" "${orchestratorPath}"`;
175
176
 
176
177
  // 모든 이벤트를 하나의 오케스트레이터로 통합
177
178
  const newHooks = {};
@@ -23,6 +23,7 @@ import { readFileSync, existsSync } from "node:fs";
23
23
  import { join, dirname } from "node:path";
24
24
  import { fileURLToPath } from "node:url";
25
25
  import { execFileSync } from "node:child_process";
26
+ import { PLUGIN_ROOT } from "./lib/resolve-root.mjs";
26
27
 
27
28
  const __dirname = dirname(fileURLToPath(import.meta.url));
28
29
  const REGISTRY_PATH =
@@ -50,10 +51,10 @@ function loadRegistry() {
50
51
  // ── 경로 변수 치환 ──────────────────────────────────────────
51
52
  function resolveCommand(cmd) {
52
53
  const home = process.env.HOME || process.env.USERPROFILE || "";
53
- const pluginRoot = process.env.CLAUDE_PLUGIN_ROOT || __dirname.replace(/[\\/]hooks$/, "");
54
54
 
55
55
  return cmd
56
- .replace(/\$\{PLUGIN_ROOT\}/g, pluginRoot)
56
+ .replace(/\$\{PLUGIN_ROOT\}/g, PLUGIN_ROOT)
57
+ .replace(/\$\{CLAUDE_PLUGIN_ROOT\}/g, PLUGIN_ROOT)
57
58
  .replace(/\$\{HOME\}/g, home)
58
59
  .replace(/\$HOME\b/g, home);
59
60
  }
@@ -155,7 +155,7 @@
155
155
  "matcher": "*",
156
156
  "command": "${HOME}/Desktop/Projects/cli/session-vault/scripts/start_hook.sh",
157
157
  "priority": 100,
158
- "enabled": true,
158
+ "enabled": false,
159
159
  "timeout": 10,
160
160
  "blocking": false,
161
161
  "description": "세션 볼트 로깅 시작"
@@ -179,7 +179,7 @@
179
179
  "matcher": "*",
180
180
  "command": "${HOME}/Desktop/Projects/cli/session-vault/scripts/export_hook.sh",
181
181
  "priority": 100,
182
- "enabled": true,
182
+ "enabled": false,
183
183
  "timeout": 30,
184
184
  "blocking": false,
185
185
  "description": "세션 트랜스크립트 내보내기"
@@ -190,7 +190,7 @@
190
190
  "matcher": "*",
191
191
  "command": "powershell -NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File \"${HOME}/.claude/scripts/mcp-cleanup.ps1\"",
192
192
  "priority": 100,
193
- "enabled": true,
193
+ "enabled": false,
194
194
  "timeout": 8,
195
195
  "blocking": false,
196
196
  "description": "MCP 고아 프로세스 정리"
@@ -0,0 +1,59 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { dirname, join, resolve } from "node:path";
4
+ import { fileURLToPath } from "node:url";
5
+
6
+ const BREADCRUMB_PATH = join(homedir(), ".claude", "scripts", ".tfx-pkg-root");
7
+
8
+ function normalizeCandidate(candidate) {
9
+ if (typeof candidate !== "string") return null;
10
+ const value = candidate.trim();
11
+ if (!value) return null;
12
+ return value;
13
+ }
14
+
15
+ function isValidPluginRoot(candidate) {
16
+ const root = normalizeCandidate(candidate);
17
+ if (!root) return false;
18
+ return existsSync(join(root, "hooks", "hook-orchestrator.mjs"));
19
+ }
20
+
21
+ function toPluginRootFromUrl(url) {
22
+ if (typeof url !== "string" || !url) return null;
23
+ try {
24
+ const sourceDir = dirname(fileURLToPath(url));
25
+ const hookScoped = sourceDir.match(/^(.*?)[\\/]hooks(?:[\\/].*)?$/);
26
+ if (hookScoped?.[1]) return hookScoped[1];
27
+ return resolve(sourceDir, "..");
28
+ } catch {
29
+ return null;
30
+ }
31
+ }
32
+
33
+ function readBreadcrumbRoot() {
34
+ if (!existsSync(BREADCRUMB_PATH)) return null;
35
+ try {
36
+ return normalizeCandidate(readFileSync(BREADCRUMB_PATH, "utf8"));
37
+ } catch {
38
+ return null;
39
+ }
40
+ }
41
+
42
+ export function resolvePluginRoot(callerUrl) {
43
+ const breadcrumbRoot = readBreadcrumbRoot();
44
+ if (isValidPluginRoot(breadcrumbRoot)) return breadcrumbRoot;
45
+
46
+ const envRoot = normalizeCandidate(process.env.CLAUDE_PLUGIN_ROOT);
47
+ if (isValidPluginRoot(envRoot)) return envRoot;
48
+
49
+ const callerFallback = toPluginRootFromUrl(callerUrl);
50
+ if (isValidPluginRoot(callerFallback)) return callerFallback;
51
+
52
+ const moduleFallback = toPluginRootFromUrl(import.meta.url) || process.cwd();
53
+ process.stderr.write(
54
+ `[resolve-root] warning: failed to resolve plugin root from breadcrumb/env/caller; fallback=${moduleFallback}\n`
55
+ );
56
+ return moduleFallback;
57
+ }
58
+
59
+ export const PLUGIN_ROOT = resolvePluginRoot(import.meta.url);
@@ -6,20 +6,23 @@
6
6
  // 파이프라인이 없으면 정상 종료를 허용한다.
7
7
 
8
8
  import { existsSync } from "node:fs";
9
- import { join } from "node:path";
10
- import { fileURLToPath } from "node:url";
9
+ import { PLUGIN_ROOT } from "./lib/resolve-root.mjs";
11
10
 
12
11
  let getPipelineStateDbPath;
12
+ let ensurePipelineTable;
13
+ let listPipelineStates;
13
14
  try {
14
- const stateModule = await import("../hub/pipeline/state.mjs");
15
- getPipelineStateDbPath = stateModule.getPipelineStateDbPath;
15
+ ({
16
+ getPipelineStateDbPath,
17
+ ensurePipelineTable,
18
+ listPipelineStates,
19
+ } = await import("../hub/pipeline/state.mjs"));
16
20
  } catch {
17
21
  // hub/pipeline 모듈 없으면 훅 무동작
18
22
  process.exit(0);
19
23
  }
20
24
 
21
- const PROJECT_ROOT = fileURLToPath(new URL("..", import.meta.url));
22
- const HUB_DB_PATH = getPipelineStateDbPath(PROJECT_ROOT);
25
+ const HUB_DB_PATH = getPipelineStateDbPath(PLUGIN_ROOT);
23
26
  const TERMINAL = new Set(["complete", "failed"]);
24
27
 
25
28
  async function checkActivePipelines() {
@@ -27,14 +30,6 @@ async function checkActivePipelines() {
27
30
 
28
31
  try {
29
32
  const { default: Database } = await import("better-sqlite3");
30
- const { ensurePipelineTable, listPipelineStates } = await import(
31
- join(
32
- process.env.CLAUDE_PLUGIN_ROOT || PROJECT_ROOT,
33
- "hub",
34
- "pipeline",
35
- "state.mjs"
36
- )
37
- );
38
33
 
39
34
  const db = new Database(HUB_DB_PATH, { readonly: true });
40
35
  ensurePipelineTable(db);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "triflux",
3
- "version": "9.7.0",
3
+ "version": "9.7.1",
4
4
  "description": "CLI-first multi-model orchestrator for Claude Code — route tasks to Codex, Gemini, and Claude",
5
5
  "type": "module",
6
6
  "bin": {
package/scripts/run.cjs CHANGED
@@ -1,62 +1,79 @@
1
- #!/usr/bin/env node
2
- "use strict";
3
-
4
- const { execFileSync } = require("child_process");
5
- const { existsSync, readFileSync } = require("fs");
6
- const { dirname, isAbsolute, join, resolve } = require("path");
7
-
8
- function resolvePluginRoot() {
9
- if (process.env.CLAUDE_PLUGIN_ROOT) return process.env.CLAUDE_PLUGIN_ROOT;
10
- return dirname(__dirname);
11
- }
12
-
13
- function resolveTargetPath(rawTarget) {
14
- if (!rawTarget || typeof rawTarget !== "string") return null;
15
-
16
- const pluginRoot = resolvePluginRoot();
17
- const trimmed = rawTarget.trim();
18
-
19
- if (trimmed.startsWith("${CLAUDE_PLUGIN_ROOT}/")) {
20
- return join(pluginRoot, trimmed.replace("${CLAUDE_PLUGIN_ROOT}/", ""));
21
- }
22
-
23
- if (trimmed.startsWith("/scripts/")) {
24
- return join(pluginRoot, trimmed.replace(/^\/+/, ""));
25
- }
26
-
27
- if (isAbsolute(trimmed)) return trimmed;
28
- return resolve(process.cwd(), trimmed);
29
- }
30
-
31
- const targetArg = process.argv[2];
32
- if (!targetArg) {
33
- process.exit(0);
34
- }
35
-
36
- const targetPath = resolveTargetPath(targetArg);
37
- if (!targetPath || !existsSync(targetPath)) {
38
- process.exit(0);
39
- }
40
-
41
- const stdinBuffer = (() => {
42
- try {
43
- return readFileSync(0);
44
- } catch {
45
- return Buffer.alloc(0);
46
- }
47
- })();
48
-
49
- try {
50
- execFileSync(process.execPath, [targetPath, ...process.argv.slice(3)], {
51
- env: process.env,
52
- stdio: ["pipe", "inherit", "inherit"],
53
- input: stdinBuffer,
54
- windowsHide: true
55
- });
56
- process.exit(0);
57
- } catch (error) {
58
- if (typeof error?.status === "number") {
59
- process.exit(error.status);
60
- }
61
- process.exit(0);
62
- }
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+
4
+ const { execFileSync } = require("child_process");
5
+ const { existsSync, readFileSync } = require("fs");
6
+ const { dirname, isAbsolute, join, resolve } = require("path");
7
+ const { homedir } = require("os");
8
+
9
+ function isValidPluginRoot(candidate) {
10
+ return typeof candidate === "string"
11
+ && candidate.trim().length > 0
12
+ && existsSync(join(candidate.trim(), "hooks", "hook-orchestrator.mjs"));
13
+ }
14
+
15
+ function resolvePluginRoot() {
16
+ const breadcrumb = join(homedir(), ".claude", "scripts", ".tfx-pkg-root");
17
+ if (existsSync(breadcrumb)) {
18
+ try {
19
+ const value = readFileSync(breadcrumb, "utf8").trim();
20
+ if (isValidPluginRoot(value)) return value;
21
+ } catch {
22
+ // breadcrumb 읽기 실패 시 다음 fallback
23
+ }
24
+ }
25
+
26
+ if (isValidPluginRoot(process.env.CLAUDE_PLUGIN_ROOT)) return process.env.CLAUDE_PLUGIN_ROOT;
27
+ return dirname(__dirname);
28
+ }
29
+
30
+ function resolveTargetPath(rawTarget) {
31
+ if (!rawTarget || typeof rawTarget !== "string") return null;
32
+
33
+ const pluginRoot = resolvePluginRoot();
34
+ const trimmed = rawTarget.trim();
35
+
36
+ if (trimmed.startsWith("${CLAUDE_PLUGIN_ROOT}/")) {
37
+ return join(pluginRoot, trimmed.replace("${CLAUDE_PLUGIN_ROOT}/", ""));
38
+ }
39
+
40
+ if (trimmed.startsWith("/scripts/")) {
41
+ return join(pluginRoot, trimmed.replace(/^\/+/, ""));
42
+ }
43
+
44
+ if (isAbsolute(trimmed)) return trimmed;
45
+ return resolve(process.cwd(), trimmed);
46
+ }
47
+
48
+ const targetArg = process.argv[2];
49
+ if (!targetArg) {
50
+ process.exit(0);
51
+ }
52
+
53
+ const targetPath = resolveTargetPath(targetArg);
54
+ if (!targetPath || !existsSync(targetPath)) {
55
+ process.exit(0);
56
+ }
57
+
58
+ const stdinBuffer = (() => {
59
+ try {
60
+ return readFileSync(0);
61
+ } catch {
62
+ return Buffer.alloc(0);
63
+ }
64
+ })();
65
+
66
+ try {
67
+ execFileSync(process.execPath, [targetPath, ...process.argv.slice(3)], {
68
+ env: process.env,
69
+ stdio: ["pipe", "inherit", "inherit"],
70
+ input: stdinBuffer,
71
+ windowsHide: true
72
+ });
73
+ process.exit(0);
74
+ } catch (error) {
75
+ if (typeof error?.status === "number") {
76
+ process.exit(error.status);
77
+ }
78
+ process.exit(0);
79
+ }
package/scripts/setup.mjs CHANGED
@@ -5,7 +5,7 @@
5
5
  // - skills/를 ~/.claude/skills/에 동기화
6
6
 
7
7
  import { copyFileSync, mkdirSync, readFileSync, writeFileSync, readdirSync, existsSync, chmodSync, unlinkSync, rmSync } from "fs";
8
- import { join, dirname } from "path";
8
+ import { join, dirname, basename } from "path";
9
9
  import { homedir } from "os";
10
10
  import { spawn, execFileSync } from "child_process";
11
11
  import { fileURLToPath } from "url";
@@ -412,13 +412,253 @@ function ensureCodexProfiles() {
412
412
  }
413
413
  }
414
414
 
415
+ const WINDOWS_DEFAULT_NODE_PATH = "C:/Program Files/nodejs/node.exe";
416
+ const MANAGED_HOOK_FILENAMES = new Set([
417
+ "safety-guard.mjs",
418
+ "agent-route-guard.mjs",
419
+ "cross-review-tracker.mjs",
420
+ "error-context.mjs",
421
+ "keyword-detector.mjs",
422
+ "pipeline-stop.mjs",
423
+ "subagent-verifier.mjs",
424
+ ]);
425
+
426
+ function toForwardPath(value) {
427
+ return String(value || "").replace(/\\/g, "/");
428
+ }
429
+
430
+ function quotePath(value) {
431
+ return `"${toForwardPath(value)}"`;
432
+ }
433
+
434
+ function normalizeCommand(value) {
435
+ return toForwardPath(value).replace(/\s+/g, " ").trim();
436
+ }
437
+
438
+ function extractManagedHookFilename(command) {
439
+ if (typeof command !== "string") return null;
440
+ const matches = command.match(/[A-Za-z0-9._-]+\.mjs/g) || [];
441
+ for (const match of matches) {
442
+ const fileName = basename(match);
443
+ if (MANAGED_HOOK_FILENAMES.has(fileName)) return fileName;
444
+ }
445
+ return null;
446
+ }
447
+
448
+ function isValidManagedHookRoot(candidateRoot) {
449
+ if (typeof candidateRoot !== "string" || !candidateRoot.trim()) return false;
450
+ const root = candidateRoot.trim();
451
+ if (!existsSync(join(root, "hooks", "hook-registry.json"))) return false;
452
+ if (!existsSync(join(root, "scripts", "run.cjs"))) return false;
453
+ if (!existsSync(join(root, "scripts", "keyword-detector.mjs"))) return false;
454
+
455
+ for (const fileName of MANAGED_HOOK_FILENAMES) {
456
+ if (!existsSync(join(root, "hooks", fileName))) return false;
457
+ }
458
+
459
+ return true;
460
+ }
461
+
462
+ function resolveHookPluginRoot() {
463
+ const envRoot = process.env.PLUGIN_ROOT || process.env.CLAUDE_PLUGIN_ROOT;
464
+ if (isValidManagedHookRoot(envRoot)) {
465
+ return toForwardPath(envRoot.trim());
466
+ }
467
+
468
+ try {
469
+ const npmCmd = process.platform === "win32" ? "npm.cmd" : "npm";
470
+ const npmGlobalRoot = execFileSync(npmCmd, ["root", "-g"], {
471
+ encoding: "utf8",
472
+ timeout: 5000,
473
+ stdio: ["ignore", "pipe", "ignore"],
474
+ windowsHide: true,
475
+ }).trim();
476
+ const npmPluginRoot = npmGlobalRoot ? join(npmGlobalRoot, "triflux") : "";
477
+ if (isValidManagedHookRoot(npmPluginRoot)) {
478
+ return toForwardPath(npmPluginRoot);
479
+ }
480
+ } catch {
481
+ // npm global root 조회 실패 시 로컬 패키지 루트를 fallback으로 사용
482
+ }
483
+
484
+ return toForwardPath(PLUGIN_ROOT);
485
+ }
486
+
487
+ function resolveManagedNodePath() {
488
+ if (process.platform === "win32" && existsSync(WINDOWS_DEFAULT_NODE_PATH)) {
489
+ return toForwardPath(WINDOWS_DEFAULT_NODE_PATH);
490
+ }
491
+ return toForwardPath(process.execPath || "node");
492
+ }
493
+
494
+ function buildManagedHookCommand(fileName, { pluginRoot, nodePath }) {
495
+ if (fileName === "keyword-detector.mjs") {
496
+ const runScript = join(pluginRoot, "scripts", "run.cjs");
497
+ const detectorScript = join(pluginRoot, "scripts", "keyword-detector.mjs");
498
+ return `${quotePath(nodePath)} ${quotePath(runScript)} ${quotePath(detectorScript)}`;
499
+ }
500
+ const hookPath = join(pluginRoot, "hooks", fileName);
501
+ return `${quotePath(nodePath)} ${quotePath(hookPath)}`;
502
+ }
503
+
504
+ function getManagedRegistryHooks(registryPath = join(PLUGIN_ROOT, "hooks", "hook-registry.json")) {
505
+ if (!existsSync(registryPath)) return [];
506
+
507
+ let registry;
508
+ try {
509
+ registry = JSON.parse(readFileSync(registryPath, "utf8"));
510
+ } catch {
511
+ return [];
512
+ }
513
+
514
+ const hooks = [];
515
+ for (const [event, eventEntries] of Object.entries(registry.events || {})) {
516
+ if (!Array.isArray(eventEntries)) continue;
517
+
518
+ for (const eventEntry of eventEntries) {
519
+ if (!eventEntry || eventEntry.enabled === false || eventEntry.source !== "triflux") continue;
520
+ const fileName = extractManagedHookFilename(eventEntry.command);
521
+ if (!fileName) continue;
522
+
523
+ hooks.push({
524
+ id: String(eventEntry.id || fileName.replace(/\.mjs$/i, "")),
525
+ event: String(event),
526
+ matcher: String(eventEntry.matcher || "*"),
527
+ fileName,
528
+ timeout: Number.isFinite(eventEntry.timeout) ? eventEntry.timeout : undefined,
529
+ blocking: typeof eventEntry.blocking === "boolean" ? eventEntry.blocking : undefined,
530
+ priority: Number.isFinite(eventEntry.priority) ? eventEntry.priority : undefined,
531
+ });
532
+ }
533
+ }
534
+
535
+ return hooks;
536
+ }
537
+
538
+ function ensureHooksInSettings({
539
+ settingsPath = join(homedir(), ".claude", "settings.json"),
540
+ registryPath = join(PLUGIN_ROOT, "hooks", "hook-registry.json"),
541
+ pluginRoot = resolveHookPluginRoot(),
542
+ nodePath = resolveManagedNodePath(),
543
+ } = {}) {
544
+ const managedHooks = getManagedRegistryHooks(registryPath);
545
+ if (managedHooks.length === 0) {
546
+ return {
547
+ ok: false,
548
+ changed: false,
549
+ total: 0,
550
+ added: [],
551
+ reason: "registry_unavailable",
552
+ };
553
+ }
554
+
555
+ let settings = {};
556
+ if (existsSync(settingsPath)) {
557
+ try {
558
+ settings = JSON.parse(readFileSync(settingsPath, "utf8"));
559
+ } catch (error) {
560
+ return {
561
+ ok: false,
562
+ changed: false,
563
+ total: managedHooks.length,
564
+ added: [],
565
+ reason: `settings_parse_failed:${error.message}`,
566
+ };
567
+ }
568
+ }
569
+ if (!settings.hooks || typeof settings.hooks !== "object") settings.hooks = {};
570
+
571
+ const added = [];
572
+ for (const hookSpec of managedHooks) {
573
+ if (!Array.isArray(settings.hooks[hookSpec.event])) settings.hooks[hookSpec.event] = [];
574
+ const eventEntries = settings.hooks[hookSpec.event];
575
+ const expectedCommand = buildManagedHookCommand(hookSpec.fileName, { pluginRoot, nodePath });
576
+ const expectedNormalizedCommand = normalizeCommand(expectedCommand);
577
+
578
+ // 중복 체크: (1) 정확한 command 일치 또는 (2) 같은 파일명의 훅이 이미 등록됨
579
+ const hasSameMatcherAndCommand = eventEntries.some((entry) =>
580
+ entry?.matcher === hookSpec.matcher &&
581
+ Array.isArray(entry.hooks) &&
582
+ entry.hooks.some((hook) => {
583
+ if (normalizeCommand(hook?.command) === expectedNormalizedCommand) return true;
584
+ // pluginRoot가 달라도 같은 훅 파일이면 중복으로 판단
585
+ const existingFileName = extractManagedHookFilename(hook?.command);
586
+ return existingFileName === hookSpec.fileName;
587
+ }),
588
+ );
589
+ if (hasSameMatcherAndCommand) continue;
590
+
591
+ const hookEntry = {
592
+ type: "command",
593
+ command: expectedCommand,
594
+ };
595
+ if (Number.isFinite(hookSpec.timeout)) hookEntry.timeout = hookSpec.timeout;
596
+ if (typeof hookSpec.blocking === "boolean") hookEntry.blocking = hookSpec.blocking;
597
+ if (Number.isFinite(hookSpec.priority)) hookEntry.priority = hookSpec.priority;
598
+
599
+ const matcherEntry = eventEntries.find(
600
+ (entry) => entry?.matcher === hookSpec.matcher && Array.isArray(entry.hooks),
601
+ );
602
+ if (matcherEntry) {
603
+ matcherEntry.hooks.push(hookEntry);
604
+ } else {
605
+ eventEntries.push({ matcher: hookSpec.matcher, hooks: [hookEntry] });
606
+ }
607
+
608
+ added.push({
609
+ id: hookSpec.id,
610
+ event: hookSpec.event,
611
+ matcher: hookSpec.matcher,
612
+ fileName: hookSpec.fileName,
613
+ });
614
+ }
615
+
616
+ if (added.length === 0) {
617
+ return {
618
+ ok: true,
619
+ changed: false,
620
+ total: managedHooks.length,
621
+ added: [],
622
+ };
623
+ }
624
+
625
+ let backupPath = null;
626
+ try {
627
+ if (existsSync(settingsPath)) {
628
+ backupPath = `${settingsPath}.bak.${Date.now()}`;
629
+ copyFileSync(settingsPath, backupPath);
630
+ } else {
631
+ const settingsDir = dirname(settingsPath);
632
+ if (!existsSync(settingsDir)) mkdirSync(settingsDir, { recursive: true });
633
+ }
634
+ writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n", "utf8");
635
+ } catch (error) {
636
+ return {
637
+ ok: false,
638
+ changed: false,
639
+ total: managedHooks.length,
640
+ added,
641
+ backupPath,
642
+ reason: `settings_write_failed:${error.message}`,
643
+ };
644
+ }
645
+
646
+ return {
647
+ ok: true,
648
+ changed: true,
649
+ total: managedHooks.length,
650
+ added,
651
+ backupPath,
652
+ };
653
+ }
654
+
415
655
  export {
416
656
  replaceProfileSection, hasProfileSection, escapeRegExp, detectDevMode,
417
657
  SYNC_MAP, BREADCRUMB_PATH, PLUGIN_ROOT, CLAUDE_DIR,
418
658
  SKILL_ALIASES, REQUIRED_CODEX_PROFILES,
419
659
  DEPRECATED_SKILLS, LEGACY_CODEX_MODELS,
420
660
  buildAliasedSkillContent, syncAliasedSkillDir, getVersion, ensureCodexProfiles,
421
- cleanupStaleSkills,
661
+ cleanupStaleSkills, extractManagedHookFilename, getManagedRegistryHooks, ensureHooksInSettings,
422
662
  };
423
663
 
424
664
  function runMcpGuardAudit() {
@@ -877,6 +1117,12 @@ if (settingsChanged) {
877
1117
  }
878
1118
  }
879
1119
 
1120
+ // ── hook-registry 기반 누락 훅 자동 등록 ──
1121
+ {
1122
+ const hookEnsureResult = ensureHooksInSettings();
1123
+ if (hookEnsureResult.changed) synced++;
1124
+ }
1125
+
880
1126
  // ── Stale PID 파일 정리 (hub 좀비 방지) ──
881
1127
 
882
1128
  const HUB_PID_FILE = join(CLAUDE_DIR, "cache", "tfx-hub", "hub.pid");