triflux 8.12.2 → 9.0.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 (49) hide show
  1. package/.claude-plugin/marketplace.json +2 -2
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/bin/triflux.mjs +64 -0
  4. package/hub/team/backend.mjs +2 -1
  5. package/hub/team/cli/commands/start/index.mjs +2 -2
  6. package/hub/team/cli/commands/start/parse-args.mjs +10 -0
  7. package/hub/workers/delegator-mcp.mjs +2 -5
  8. package/package.json +1 -1
  9. package/scripts/cache-buildup.mjs +24 -395
  10. package/scripts/cache-doctor.mjs +149 -0
  11. package/scripts/cache-warmup.mjs +514 -0
  12. package/scripts/cross-review-gate.mjs +180 -0
  13. package/scripts/cross-review-tracker.mjs +279 -0
  14. package/scripts/headless-guard.mjs +38 -0
  15. package/scripts/lib/env-probe.mjs +130 -0
  16. package/scripts/lib/mcp-filter.mjs +730 -720
  17. package/scripts/lib/mcp-manifest.mjs +79 -0
  18. package/scripts/mcp-gateway-config.mjs +104 -7
  19. package/scripts/mcp-gateway-start.mjs +7 -0
  20. package/scripts/mcp-gateway-verify.mjs +15 -1
  21. package/scripts/preflight-cache.mjs +68 -137
  22. package/scripts/session-spawn-helper.mjs +184 -0
  23. package/scripts/setup.mjs +7 -8
  24. package/scripts/tfx-route-worker.mjs +59 -1
  25. package/skills/merge-worktree/SKILL.md +144 -0
  26. package/skills/tfx-analysis/SKILL.md +1 -0
  27. package/skills/tfx-auto/SKILL.md +1 -0
  28. package/skills/tfx-auto-codex/SKILL.md +1 -0
  29. package/skills/tfx-autopilot/SKILL.md +1 -2
  30. package/skills/tfx-codex/SKILL.md +2 -0
  31. package/skills/tfx-codex-swarm/SKILL.md +62 -18
  32. package/skills/tfx-codex-swarm/mcp-daemon/start-daemons.ps1 +54 -0
  33. package/skills/tfx-codex-swarm/mcp-daemon/stop-daemons.ps1 +15 -0
  34. package/skills/tfx-consensus/SKILL.md +1 -0
  35. package/skills/tfx-deep-analysis/SKILL.md +1 -0
  36. package/skills/tfx-deep-plan/SKILL.md +1 -0
  37. package/skills/tfx-deep-qa/SKILL.md +1 -0
  38. package/skills/tfx-deep-research/SKILL.md +1 -0
  39. package/skills/tfx-deep-review/SKILL.md +1 -0
  40. package/skills/tfx-doctor/SKILL.md +5 -0
  41. package/skills/tfx-gemini/SKILL.md +1 -0
  42. package/skills/tfx-hub/SKILL.md +1 -0
  43. package/skills/tfx-multi/SKILL.md +1 -0
  44. package/skills/tfx-plan/SKILL.md +1 -0
  45. package/skills/tfx-qa/SKILL.md +1 -0
  46. package/skills/tfx-ralph/SKILL.md +2 -5
  47. package/skills/tfx-research/SKILL.md +1 -0
  48. package/skills/tfx-review/SKILL.md +2 -0
  49. package/skills/tfx-setup/SKILL.md +182 -7
@@ -0,0 +1,79 @@
1
+ // scripts/lib/mcp-manifest.mjs
2
+ // MCP 서버 활성화 매니페스트 — 단일 진실 소스.
3
+ // tfx-setup 위저드가 저장하고, gateway/filter가 참조한다.
4
+
5
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
6
+ import { join, dirname } from 'node:path';
7
+ import { homedir } from 'node:os';
8
+
9
+ export const MANIFEST_PATH = join(homedir(), '.claude', 'cache', 'mcp-enabled.json');
10
+
11
+ /** API 키 불필요 — 항상 활성화 */
12
+ export const CORE_SERVERS = Object.freeze(['context7', 'serena']);
13
+
14
+ /** 검색 MCP — API 키 필요 */
15
+ export const SEARCH_SERVERS = Object.freeze([
16
+ { name: 'brave-search', envVars: ['BRAVE_API_KEY'] },
17
+ { name: 'exa', envVars: ['EXA_API_KEY'] },
18
+ { name: 'tavily', envVars: ['TAVILY_API_KEY'] },
19
+ ]);
20
+
21
+ /** 통합 MCP — API 키 + 추가 설정 필요 */
22
+ export const INTEGRATION_SERVERS = Object.freeze([
23
+ { name: 'jira', envVars: ['JIRA_API_TOKEN', 'JIRA_EMAIL', 'JIRA_INSTANCE_URL'] },
24
+ { name: 'notion', envVars: ['NOTION_TOKEN'] },
25
+ { name: 'notion-guest', envVars: ['NOTION_TOKEN'] },
26
+ ]);
27
+
28
+ export function readManifest() {
29
+ if (!existsSync(MANIFEST_PATH)) return null;
30
+ try {
31
+ return JSON.parse(readFileSync(MANIFEST_PATH, 'utf8'));
32
+ } catch {
33
+ return null;
34
+ }
35
+ }
36
+
37
+ export function writeManifest(enabledServers) {
38
+ const dir = dirname(MANIFEST_PATH);
39
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
40
+ const manifest = {
41
+ version: 1,
42
+ updatedAt: new Date().toISOString(),
43
+ enabled: [...new Set([...CORE_SERVERS, ...enabledServers])],
44
+ };
45
+ writeFileSync(MANIFEST_PATH, JSON.stringify(manifest, null, 2));
46
+ return manifest;
47
+ }
48
+
49
+ /**
50
+ * 매니페스트 기준으로 활성 서버만 필터링.
51
+ * @param {Array<string|{name:string}>} allServers — 전체 서버 목록
52
+ * @returns {Array} 활성 서버 목록. 매니페스트 미존재 시 null (레거시 모드).
53
+ */
54
+ export function filterByManifest(allServers) {
55
+ const manifest = readManifest();
56
+ if (!manifest) return null;
57
+ const enabled = new Set(manifest.enabled || []);
58
+ for (const core of CORE_SERVERS) enabled.add(core);
59
+ return allServers.filter((s) => enabled.has(typeof s === 'string' ? s : s.name));
60
+ }
61
+
62
+ /**
63
+ * 단일 서버 활성화 여부 확인.
64
+ * 매니페스트 미존재 시 true (레거시 호환).
65
+ */
66
+ export function isServerEnabled(serverName) {
67
+ const manifest = readManifest();
68
+ if (!manifest) return true;
69
+ if (CORE_SERVERS.includes(serverName)) return true;
70
+ return (manifest.enabled || []).includes(serverName);
71
+ }
72
+
73
+ /** 특정 서버에 필요한 환경변수 중 누락된 것 반환 */
74
+ export function getMissingEnvVars(serverName) {
75
+ const all = [...SEARCH_SERVERS, ...INTEGRATION_SERVERS];
76
+ const entry = all.find((s) => s.name === serverName);
77
+ if (!entry) return [];
78
+ return entry.envVars.filter((k) => !process.env[k]);
79
+ }
@@ -4,6 +4,12 @@
4
4
  // node mcp-gateway-config.mjs --disable # SSE → stdio (복원)
5
5
 
6
6
  import { execSync } from 'node:child_process';
7
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
8
+ import { homedir } from 'node:os';
9
+ import { join, dirname } from 'node:path';
10
+ import { isServerEnabled } from './lib/mcp-manifest.mjs';
11
+
12
+ const BACKUP_FILE = join(homedir(), '.claude', 'cache', 'mcp-pre-gateway.json');
7
13
 
8
14
  export const GATEWAY_SERVERS = [
9
15
  { name: 'context7', port: 8100, stdioCmd: 'cmd /c npx -y @upstash/context7-mcp@latest' },
@@ -35,18 +41,70 @@ function run(cmd) {
35
41
  }
36
42
 
37
43
  function removeMcp(name) {
38
- // 여러 scope에서 제거 시도 (user → local)
39
44
  run(`claude mcp remove "${name}" -s user`);
40
45
  run(`claude mcp remove "${name}" -s local`);
41
46
  }
42
47
 
48
+ // ── 스냅샷: enable 전 기존 MCP 설정 백업 ──
49
+
50
+ function captureCurrentMcpState() {
51
+ const servers = {};
52
+ for (const { name } of GATEWAY_SERVERS) {
53
+ if (SKIP_SERVERS.has(name)) continue;
54
+ // claude mcp get으로 현재 등록 상태 확인
55
+ try {
56
+ const out = execSync(`claude mcp get "${name}" -s user`, {
57
+ stdio: 'pipe', encoding: 'utf8', timeout: 5000, env: EXEC_ENV,
58
+ }).trim();
59
+ servers[name] = { scope: 'user', raw: out };
60
+ } catch {
61
+ // user에 없으면 local 확인
62
+ try {
63
+ const out = execSync(`claude mcp get "${name}" -s local`, {
64
+ stdio: 'pipe', encoding: 'utf8', timeout: 5000, env: EXEC_ENV,
65
+ }).trim();
66
+ servers[name] = { scope: 'local', raw: out };
67
+ } catch {
68
+ servers[name] = null; // 기존에 없었음
69
+ }
70
+ }
71
+ }
72
+ return servers;
73
+ }
74
+
75
+ function saveBackup(servers) {
76
+ const dir = dirname(BACKUP_FILE);
77
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
78
+ const backup = { captured_at: new Date().toISOString(), servers };
79
+ writeFileSync(BACKUP_FILE, JSON.stringify(backup, null, 2));
80
+ console.log(` [BACKUP] ${BACKUP_FILE}`);
81
+ }
82
+
83
+ function loadBackup() {
84
+ if (!existsSync(BACKUP_FILE)) return null;
85
+ try {
86
+ return JSON.parse(readFileSync(BACKUP_FILE, 'utf8'));
87
+ } catch { return null; }
88
+ }
89
+
90
+ // ── enable: 스냅샷 → remove → SSE add (실패 시 rollback) ──
91
+
43
92
  function enableSse() {
44
93
  console.log('Switching MCP servers to SSE mode...\n');
94
+
95
+ // 1) 현재 상태 스냅샷
96
+ const snapshot = captureCurrentMcpState();
97
+ saveBackup(snapshot);
98
+
45
99
  let ok = 0;
46
100
  let fail = 0;
47
101
 
48
102
  for (const { name, port } of GATEWAY_SERVERS) {
49
103
  if (SKIP_SERVERS.has(name)) continue;
104
+ if (!isServerEnabled(name)) {
105
+ console.log(` [SKIP] ${name} — manifest에서 비활성`);
106
+ continue;
107
+ }
50
108
 
51
109
  removeMcp(name);
52
110
  const url = `http://localhost:${port}/sse`;
@@ -56,7 +114,15 @@ function enableSse() {
56
114
  console.log(` [SSE] ${name} → ${url}`);
57
115
  ok++;
58
116
  } else {
59
- console.error(` [FAIL] ${name}`);
117
+ // add 실패 → 원본 복원 시도
118
+ console.error(` [FAIL] ${name} — rollback 시도`);
119
+ const orig = snapshot[name];
120
+ if (orig) {
121
+ // H1 fix: orig.raw를 shell-escape하여 injection 방지
122
+ const safeRaw = (orig.raw || '').replace(/[;`$(){}|&<>]/g, '');
123
+ const restored = safeRaw ? run(`claude mcp add "${name}" -s ${orig.scope} -- ${safeRaw}`) : false;
124
+ console.error(` [ROLLBACK] ${name}: ${restored ? 'ok' : 'FAIL'}`);
125
+ }
60
126
  fail++;
61
127
  }
62
128
  }
@@ -64,27 +130,58 @@ function enableSse() {
64
130
  console.log(`\nDone: ${ok} switched, ${fail} failed`);
65
131
  }
66
132
 
133
+ // ── disable: 백업에서 서버별 원복 ──
134
+
67
135
  function disableSse() {
68
- console.log('Restoring MCP servers to stdio mode...\n');
136
+ console.log('Restoring MCP servers from backup...\n');
137
+ const backup = loadBackup();
138
+
139
+ // C1 fix: 백업 없으면 전체 삭제 방지
140
+ if (!backup) {
141
+ console.error('No backup found — cannot restore. Run --enable first to create a backup.');
142
+ process.exit(1);
143
+ }
144
+
69
145
  let ok = 0;
70
146
  let fail = 0;
71
147
 
72
148
  for (const { name, stdioCmd } of GATEWAY_SERVERS) {
73
149
  if (SKIP_SERVERS.has(name)) continue;
74
150
 
151
+ const orig = backup.servers?.[name];
152
+
153
+ if (orig === null || orig === undefined) {
154
+ // 기존에 없었던 서버 → triflux가 추가한 것 → remove만
155
+ removeMcp(name);
156
+ console.log(` [REMOVE] ${name} — triflux가 추가한 서버, 원본 없음`);
157
+ ok++;
158
+ continue;
159
+ }
160
+
161
+ // H2 fix: 백업의 scope/raw를 사용하여 원본 복원, fallback으로 stdioCmd
75
162
  removeMcp(name);
76
- const success = run(`claude mcp add "${name}" -s user -- ${stdioCmd}`);
163
+ const restoreScope = orig.scope || 'user';
164
+ const restoreCmd = orig.raw && orig.raw.trim() ? orig.raw.trim() : stdioCmd;
165
+ const success = run(`claude mcp add "${name}" -s ${restoreScope} -- ${restoreCmd}`);
77
166
 
78
167
  if (success) {
79
- console.log(` [stdio] ${name} → ${stdioCmd}`);
168
+ console.log(` [RESTORE] ${name} → scope=${restoreScope}`);
80
169
  ok++;
81
170
  } else {
82
- console.error(` [FAIL] ${name}`);
83
- fail++;
171
+ // fallback: stdioCmd로 재시도
172
+ const fallback = run(`claude mcp add "${name}" -s user -- ${stdioCmd}`);
173
+ if (fallback) {
174
+ console.log(` [FALLBACK] ${name} → stdio (원본 복원 실패, 기본값 사용)`);
175
+ ok++;
176
+ } else {
177
+ console.error(` [FAIL] ${name}`);
178
+ fail++;
179
+ }
84
180
  }
85
181
  }
86
182
 
87
183
  console.log(`\nDone: ${ok} restored, ${fail} failed`);
184
+ console.log(`Backup preserved at: ${BACKUP_FILE}`);
88
185
  }
89
186
 
90
187
  function printUsage() {
@@ -9,6 +9,7 @@ import { existsSync, readFileSync, writeFileSync, unlinkSync } from 'node:fs';
9
9
  import { join } from 'node:path';
10
10
  import { tmpdir } from 'node:os';
11
11
  import { createConnection } from 'node:net';
12
+ import { isServerEnabled } from './lib/mcp-manifest.mjs';
12
13
 
13
14
  const PID_FILE = join(tmpdir(), 'tfx-gateway-pids.json');
14
15
  const STARTUP_WAIT_MS = 8000;
@@ -103,6 +104,12 @@ async function startAll() {
103
104
  continue;
104
105
  }
105
106
 
107
+ // 매니페스트 체크 (위저드에서 비활성화한 서버)
108
+ if (!isServerEnabled(srv.name)) {
109
+ console.log(`[SKIP] ${srv.name} — manifest에서 비활성`);
110
+ continue;
111
+ }
112
+
106
113
  // 필수 환경변수 체크
107
114
  const missing = srv.envVars.filter((k) => !process.env[k]);
108
115
  if (missing.length > 0) {
@@ -1,7 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  // mcp-gateway-verify.mjs — supergateway SSE 엔드포인트 헬스체크
3
3
 
4
- const ENDPOINTS = [
4
+ import { readManifest } from './lib/mcp-manifest.mjs';
5
+
6
+ const ALL_ENDPOINTS = [
5
7
  { name: 'context7', port: 8100 },
6
8
  { name: 'brave-search', port: 8101 },
7
9
  { name: 'exa', port: 8102 },
@@ -12,6 +14,18 @@ const ENDPOINTS = [
12
14
  { name: 'notion-guest', port: 8107 },
13
15
  ];
14
16
 
17
+ const manifest = readManifest();
18
+ if (!manifest) {
19
+ console.log('gateway: not configured (no manifest)');
20
+ process.exit(0);
21
+ }
22
+ const enabled = new Set(manifest.enabled || []);
23
+ const ENDPOINTS = ALL_ENDPOINTS.filter((e) => enabled.has(e.name));
24
+ if (ENDPOINTS.length === 0) {
25
+ console.log('gateway: no enabled servers');
26
+ process.exit(0);
27
+ }
28
+
15
29
  async function checkHealth(name, port) {
16
30
  const start = Date.now();
17
31
  try {
@@ -1,137 +1,68 @@
1
- #!/usr/bin/env node
2
- // scripts/preflight-cache.mjs — 세션 시작 시 preflight 점검 캐싱
3
-
4
- import { writeFileSync, mkdirSync, existsSync, readFileSync } from "node:fs";
5
- import { join, dirname } from "node:path";
6
- import { homedir } from "node:os";
7
- import { execSync, spawn } from "node:child_process";
8
- import { fileURLToPath } from "node:url";
9
-
10
- const __filename = fileURLToPath(import.meta.url);
11
- const PKG_ROOT = join(dirname(__filename), "..");
12
-
13
- // 동기 대기 (Atomics.wait — Node.js main thread에서 사용 가능)
14
- const _sab = new Int32Array(new SharedArrayBuffer(4));
15
- function sleepSync(ms) { Atomics.wait(_sab, 0, 0, ms); }
16
-
17
- const CACHE_DIR = join(homedir(), ".claude", "cache");
18
- const CACHE_FILE = join(CACHE_DIR, "tfx-preflight.json");
19
- const CACHE_TTL_MS = 30_000; // 30초
20
-
21
- function checkHub() {
22
- // 1차 시도
23
- try {
24
- const res = execSync("curl -sf http://127.0.0.1:27888/status", { timeout: 3000, encoding: "utf8", windowsHide: true });
25
- const data = JSON.parse(res);
26
- return { ok: true, state: data?.hub?.state || "unknown", pid: data?.pid };
27
- } catch {}
28
-
29
- // Hub 미응답 → 자동 재시작 시도 (bridge.mjs tryRestartHub 동기 버전)
30
- const serverPath = join(PKG_ROOT, "hub", "server.mjs");
31
- if (!existsSync(serverPath)) return { ok: false, state: "unreachable", restart: "no_server" };
32
-
33
- try {
34
- const child = spawn(process.execPath, [serverPath], {
35
- detached: true,
36
- stdio: "ignore",
37
- windowsHide: true,
38
- });
39
- child.unref();
40
- } catch {
41
- return { ok: false, state: "unreachable", restart: "spawn_failed" };
42
- }
43
-
44
- // 최대 4초 폴링 (500ms × 8)
45
- for (let i = 0; i < 8; i++) {
46
- sleepSync(500);
47
- try {
48
- const res = execSync("curl -sf http://127.0.0.1:27888/status", { timeout: 1000, encoding: "utf8", windowsHide: true });
49
- const data = JSON.parse(res);
50
- if (data?.hub?.state === "healthy") {
51
- return { ok: true, state: "healthy", pid: data?.pid, restarted: true };
52
- }
53
- } catch {}
54
- }
55
-
56
- return { ok: false, state: "unreachable", restart: "timeout" };
57
- }
58
-
59
- function checkRoute() {
60
- const routePath = join(homedir(), ".claude", "scripts", "tfx-route.sh");
61
- return { ok: existsSync(routePath), path: routePath };
62
- }
63
-
64
- function checkCli(name) {
65
- try {
66
- const path = execSync(`which ${name} 2>/dev/null || where ${name} 2>nul`, { encoding: "utf8", timeout: 2000, windowsHide: true }).trim();
67
- return { ok: !!path, path };
68
- } catch {
69
- return { ok: false };
70
- }
71
- }
72
-
73
- /** Codex auth.json의 JWT에서 chatgpt_plan_type 추출 (pro/plus/free) */
74
- function detectCodexPlan() {
75
- try {
76
- const authPath = join(homedir(), ".codex", "auth.json");
77
- if (!existsSync(authPath)) return { plan: "unknown", source: "no_auth" };
78
- const auth = JSON.parse(readFileSync(authPath, "utf8"));
79
- if (auth.auth_mode !== "chatgpt") return { plan: "api", source: "api_key" };
80
- const token = auth.tokens?.id_token || auth.tokens?.access_token;
81
- if (!token) return { plan: "unknown", source: "no_token" };
82
- // JWT payload = 2번째 파트, base64url 디코딩
83
- const payload = JSON.parse(Buffer.from(token.split(".")[1], "base64url").toString());
84
- const plan = payload?.["https://api.openai.com/auth"]?.chatgpt_plan_type || "unknown";
85
- return { plan, source: "jwt" };
86
- } catch {
87
- return { plan: "unknown", source: "error" };
88
- }
89
- }
90
-
91
- function runPreflight() {
92
- const result = {
93
- timestamp: Date.now(),
94
- hub: checkHub(),
95
- route: checkRoute(),
96
- codex: checkCli("codex"),
97
- gemini: checkCli("gemini"),
98
- codex_plan: detectCodexPlan(),
99
- ok: false,
100
- };
101
- result.ok = result.hub.ok && result.route.ok;
102
-
103
- // CLI 가용성 → available_agents (triage에서 참조)
104
- const agents = [];
105
- if (result.codex.ok) agents.push("codex");
106
- if (result.gemini.ok) agents.push("gemini");
107
- agents.push("claude"); // claude는 항상 가용
108
- result.available_agents = agents;
109
-
110
- return result;
111
- }
112
-
113
- // 캐시 읽기 (TTL 검증 포함)
114
- export function readPreflightCache() {
115
- try {
116
- const data = JSON.parse(readFileSync(CACHE_FILE, "utf8"));
117
- if (Date.now() - data.timestamp < CACHE_TTL_MS) return data;
118
- } catch {}
119
- return null;
120
- }
121
-
122
- // 메인 실행
123
- if (process.argv[1]?.endsWith("preflight-cache.mjs")) {
124
- const result = runPreflight();
125
- mkdirSync(CACHE_DIR, { recursive: true });
126
- writeFileSync(CACHE_FILE, JSON.stringify(result, null, 2));
127
- // 간결 출력 (hook stdout)
128
- const summary = result.ok ? "preflight: ok" : "preflight: FAIL";
129
- const details = [];
130
- if (!result.hub.ok) details.push("hub:" + result.hub.state);
131
- else if (result.hub.restarted) details.push("hub:restarted");
132
- if (!result.route.ok) details.push("route:missing");
133
- if (result.available_agents.length === 1) details.push("agents:claude-only");
134
- console.log(details.length ? `${summary} (${details.join(", ")})` : summary);
135
- }
136
-
137
- export { runPreflight, CACHE_FILE, CACHE_TTL_MS };
1
+ #!/usr/bin/env node
2
+ // scripts/preflight-cache.mjs — 세션 시작 시 preflight 점검 캐싱
3
+
4
+ import { writeFileSync, mkdirSync, existsSync, readFileSync } from "node:fs";
5
+ import { join, dirname } from "node:path";
6
+ import { homedir } from "node:os";
7
+ import { fileURLToPath } from "node:url";
8
+ import { checkCli, checkHub, detectCodexPlan } from "./lib/env-probe.mjs";
9
+
10
+ const __filename = fileURLToPath(import.meta.url);
11
+ const PKG_ROOT = join(dirname(__filename), "..");
12
+
13
+ const CACHE_DIR = join(homedir(), ".claude", "cache");
14
+ const CACHE_FILE = join(CACHE_DIR, "tfx-preflight.json");
15
+ const CACHE_TTL_MS = 30_000; // 30초
16
+
17
+ function checkRoute() {
18
+ const routePath = join(homedir(), ".claude", "scripts", "tfx-route.sh");
19
+ return { ok: existsSync(routePath), path: routePath };
20
+ }
21
+
22
+ function runPreflight() {
23
+ const result = {
24
+ timestamp: Date.now(),
25
+ hub: checkHub({ pkgRoot: PKG_ROOT }),
26
+ route: checkRoute(),
27
+ codex: checkCli("codex"),
28
+ gemini: checkCli("gemini"),
29
+ codex_plan: detectCodexPlan(),
30
+ ok: false,
31
+ };
32
+ result.ok = result.hub.ok && result.route.ok;
33
+
34
+ // CLI 가용성 → available_agents (triage에서 참조)
35
+ const agents = [];
36
+ if (result.codex.ok) agents.push("codex");
37
+ if (result.gemini.ok) agents.push("gemini");
38
+ agents.push("claude"); // claude는 항상 가용
39
+ result.available_agents = agents;
40
+
41
+ return result;
42
+ }
43
+
44
+ // 캐시 읽기 (TTL 검증 포함)
45
+ export function readPreflightCache() {
46
+ try {
47
+ const data = JSON.parse(readFileSync(CACHE_FILE, "utf8"));
48
+ if (Date.now() - data.timestamp < CACHE_TTL_MS) return data;
49
+ } catch {}
50
+ return null;
51
+ }
52
+
53
+ // 메인 실행
54
+ if (process.argv[1]?.endsWith("preflight-cache.mjs")) {
55
+ const result = runPreflight();
56
+ mkdirSync(CACHE_DIR, { recursive: true });
57
+ writeFileSync(CACHE_FILE, JSON.stringify(result, null, 2));
58
+ // 간결 출력 (hook stdout)
59
+ const summary = result.ok ? "preflight: ok" : "preflight: FAIL";
60
+ const details = [];
61
+ if (!result.hub.ok) details.push("hub:" + result.hub.state);
62
+ else if (result.hub.restarted) details.push("hub:restarted");
63
+ if (!result.route.ok) details.push("route:missing");
64
+ if (result.available_agents.length === 1) details.push("agents:claude-only");
65
+ console.log(details.length ? `${summary} (${details.join(", ")})` : summary);
66
+ }
67
+
68
+ export { runPreflight, CACHE_FILE, CACHE_TTL_MS };