triflux 10.3.4 → 10.7.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 (329) hide show
  1. package/LICENSE +21 -21
  2. package/bin/tfx-doctor-tui.mjs +1 -1
  3. package/bin/tfx-doctor.mjs +6 -1
  4. package/bin/tfx-profile.mjs +1 -1
  5. package/bin/tfx-setup-tui.mjs +1 -1
  6. package/bin/tfx-setup.mjs +6 -1
  7. package/bin/triflux.mjs +2396 -1140
  8. package/hooks/agent-route-guard.mjs +12 -8
  9. package/hooks/cross-review-tracker.mjs +21 -8
  10. package/hooks/error-context.mjs +19 -7
  11. package/hooks/hook-adaptive-collector.mjs +18 -16
  12. package/hooks/hook-manager.mjs +93 -32
  13. package/hooks/hook-orchestrator.mjs +108 -24
  14. package/hooks/hook-registry.json +11 -0
  15. package/hooks/keyword-rules.json +6 -10
  16. package/hooks/lib/resolve-root.mjs +1 -1
  17. package/hooks/mcp-config-watcher.mjs +6 -2
  18. package/hooks/pipeline-stop.mjs +3 -6
  19. package/hooks/safety-guard.mjs +99 -28
  20. package/hooks/session-start-fast.mjs +143 -0
  21. package/hooks/subagent-verifier.mjs +5 -4
  22. package/hub/account-broker.mjs +256 -60
  23. package/hub/adaptive-diagnostic.mjs +75 -48
  24. package/hub/adaptive-inject.mjs +95 -57
  25. package/hub/adaptive-memory.mjs +156 -42
  26. package/hub/adaptive.mjs +60 -31
  27. package/hub/assign-callbacks.mjs +67 -30
  28. package/hub/bridge.mjs +0 -1
  29. package/hub/cli-adapter-base.mjs +200 -48
  30. package/hub/codex-adapter.mjs +76 -96
  31. package/hub/codex-compat.mjs +3 -3
  32. package/hub/codex-preflight.mjs +63 -37
  33. package/hub/delegator/contracts.mjs +19 -23
  34. package/hub/delegator/index.mjs +3 -3
  35. package/hub/delegator/service.mjs +88 -64
  36. package/hub/delegator/tool-definitions.mjs +5 -5
  37. package/hub/fullcycle.mjs +33 -17
  38. package/hub/gemini-adapter.mjs +69 -94
  39. package/hub/hitl.mjs +89 -30
  40. package/hub/intent.mjs +161 -38
  41. package/hub/lib/cache-guard.mjs +43 -17
  42. package/hub/lib/mcp-response-cache.mjs +66 -32
  43. package/hub/lib/memory-store.mjs +285 -111
  44. package/hub/lib/path-utils.mjs +35 -37
  45. package/hub/lib/process-utils.mjs +106 -37
  46. package/hub/lib/spawn-trace.mjs +527 -0
  47. package/hub/lib/ssh-command.mjs +34 -4
  48. package/hub/lib/ssh-retry.mjs +5 -1
  49. package/hub/lib/uuidv7.mjs +4 -3
  50. package/hub/memory-doctor.mjs +266 -106
  51. package/hub/middleware/request-logger.mjs +61 -34
  52. package/hub/paths.mjs +9 -9
  53. package/hub/pipeline/gates/confidence.mjs +34 -15
  54. package/hub/pipeline/gates/consensus.mjs +27 -15
  55. package/hub/pipeline/gates/index.mjs +7 -3
  56. package/hub/pipeline/gates/selfcheck.mjs +57 -19
  57. package/hub/pipeline/index.mjs +77 -42
  58. package/hub/pipeline/state.mjs +10 -10
  59. package/hub/pipeline/transitions.mjs +40 -23
  60. package/hub/platform.mjs +57 -48
  61. package/hub/promote-penalties.mjs +25 -7
  62. package/hub/quality/deslop.mjs +70 -49
  63. package/hub/research.mjs +32 -25
  64. package/hub/router.mjs +240 -107
  65. package/hub/routing/complexity.mjs +132 -29
  66. package/hub/routing/index.mjs +17 -12
  67. package/hub/routing/q-learning.mjs +76 -28
  68. package/hub/server.mjs +4 -4
  69. package/hub/session-fingerprint.mjs +126 -60
  70. package/hub/state.mjs +84 -43
  71. package/hub/store-adapter.mjs +59 -26
  72. package/hub/store.mjs +356 -153
  73. package/hub/team/agent-map.json +22 -7
  74. package/hub/team/ansi.mjs +186 -122
  75. package/hub/team/backend.mjs +28 -10
  76. package/hub/team/cli/commands/attach.mjs +29 -9
  77. package/hub/team/cli/commands/control.mjs +29 -8
  78. package/hub/team/cli/commands/debug.mjs +32 -11
  79. package/hub/team/cli/commands/focus.mjs +38 -11
  80. package/hub/team/cli/commands/interrupt.mjs +18 -6
  81. package/hub/team/cli/commands/kill.mjs +16 -5
  82. package/hub/team/cli/commands/list.mjs +11 -4
  83. package/hub/team/cli/commands/send.mjs +19 -6
  84. package/hub/team/cli/commands/start/index.mjs +154 -31
  85. package/hub/team/cli/commands/start/parse-args.mjs +38 -11
  86. package/hub/team/cli/commands/start/start-headless.mjs +112 -36
  87. package/hub/team/cli/commands/start/start-in-process.mjs +12 -2
  88. package/hub/team/cli/commands/start/start-mux.mjs +70 -21
  89. package/hub/team/cli/commands/start/start-wt.mjs +29 -12
  90. package/hub/team/cli/commands/status.mjs +43 -14
  91. package/hub/team/cli/commands/stop.mjs +11 -4
  92. package/hub/team/cli/commands/task.mjs +8 -3
  93. package/hub/team/cli/commands/tasks.mjs +1 -1
  94. package/hub/team/cli/index.mjs +2 -2
  95. package/hub/team/cli/manifest.mjs +38 -8
  96. package/hub/team/cli/render.mjs +30 -8
  97. package/hub/team/cli/services/attach-fallback.mjs +31 -11
  98. package/hub/team/cli/services/hub-client.mjs +42 -14
  99. package/hub/team/cli/services/member-selector.mjs +11 -4
  100. package/hub/team/cli/services/native-control.mjs +48 -21
  101. package/hub/team/cli/services/runtime-mode.mjs +2 -1
  102. package/hub/team/cli/services/state-store.mjs +25 -8
  103. package/hub/team/cli/services/task-model.mjs +16 -6
  104. package/hub/team/conductor-mesh-bridge.mjs +24 -23
  105. package/hub/team/conductor.mjs +8 -4
  106. package/hub/team/dashboard-anchor.mjs +4 -5
  107. package/hub/team/dashboard-layout.mjs +3 -1
  108. package/hub/team/dashboard-open.mjs +41 -21
  109. package/hub/team/dashboard.mjs +76 -28
  110. package/hub/team/event-log.mjs +18 -10
  111. package/hub/team/handoff.mjs +31 -15
  112. package/hub/team/headless.mjs +2 -1
  113. package/hub/team/health-probe.mjs +69 -54
  114. package/hub/team/launcher-template.mjs +16 -13
  115. package/hub/team/native-supervisor.mjs +65 -21
  116. package/hub/team/native.mjs +74 -35
  117. package/hub/team/nativeProxy.mjs +184 -113
  118. package/hub/team/notify.mjs +119 -76
  119. package/hub/team/orchestrator.mjs +9 -4
  120. package/hub/team/pane.mjs +12 -7
  121. package/hub/team/process-cleanup.mjs +25 -16
  122. package/hub/team/psmux.mjs +491 -201
  123. package/hub/team/remote-probe.mjs +68 -52
  124. package/hub/team/remote-session.mjs +117 -59
  125. package/hub/team/remote-watcher.mjs +61 -33
  126. package/hub/team/routing.mjs +51 -25
  127. package/hub/team/runtime-strategy.mjs +3 -1
  128. package/hub/team/session.mjs +98 -34
  129. package/hub/team/staleState.mjs +72 -30
  130. package/hub/team/swarm-locks.mjs +15 -13
  131. package/hub/team/swarm-planner.mjs +32 -21
  132. package/hub/team/swarm-reconciler.mjs +48 -23
  133. package/hub/team/tui-lite.mjs +266 -68
  134. package/hub/team/tui-remote-adapter.mjs +14 -10
  135. package/hub/team/tui-viewer.mjs +99 -43
  136. package/hub/team/tui.mjs +708 -271
  137. package/hub/team/worktree-lifecycle.mjs +152 -58
  138. package/hub/team/wt-manager.mjs +24 -14
  139. package/hub/token-mode.mjs +71 -71
  140. package/hub/tray.mjs +66 -23
  141. package/hub/workers/claude-worker.mjs +162 -118
  142. package/hub/workers/codex-mcp.mjs +192 -141
  143. package/hub/workers/delegator-mcp.mjs +507 -333
  144. package/hub/workers/factory.mjs +8 -8
  145. package/hub/workers/gemini-worker.mjs +115 -84
  146. package/hub/workers/interface.mjs +6 -1
  147. package/hub/workers/worker-utils.mjs +21 -14
  148. package/hud/colors.mjs +27 -9
  149. package/hud/constants.mjs +162 -26
  150. package/hud/context-monitor.mjs +82 -41
  151. package/hud/hud-qos-status.mjs +129 -49
  152. package/hud/mission-board.mjs +6 -3
  153. package/hud/providers/claude.mjs +226 -115
  154. package/hud/providers/codex.mjs +62 -22
  155. package/hud/providers/gemini.mjs +168 -56
  156. package/hud/renderers.mjs +384 -119
  157. package/hud/terminal.mjs +101 -31
  158. package/hud/utils.mjs +78 -38
  159. package/mesh/index.mjs +11 -5
  160. package/mesh/mesh-budget.mjs +18 -9
  161. package/mesh/mesh-heartbeat.mjs +1 -1
  162. package/mesh/mesh-queue.mjs +3 -5
  163. package/mesh/mesh-router.mjs +5 -4
  164. package/package.json +2 -1
  165. package/scripts/__tests__/gen-skill-docs.test.mjs +36 -7
  166. package/scripts/__tests__/keyword-detector.test.mjs +77 -28
  167. package/scripts/__tests__/mcp-guard-engine.test.mjs +58 -20
  168. package/scripts/__tests__/remote-spawn-transfer.test.mjs +30 -19
  169. package/scripts/__tests__/remote-spawn.test.mjs +10 -4
  170. package/scripts/__tests__/session-start-fast.test.mjs +36 -0
  171. package/scripts/__tests__/skill-template.test.mjs +98 -50
  172. package/scripts/__tests__/smoke.test.mjs +1 -1
  173. package/scripts/__tests__/spawn-trace.test.mjs +102 -0
  174. package/scripts/__tests__/tfx-doctor-diagnose.test.mjs +48 -0
  175. package/scripts/cache-doctor.mjs +11 -4
  176. package/scripts/cache-warmup.mjs +96 -37
  177. package/scripts/claudemd-sync.mjs +27 -17
  178. package/scripts/codex-gateway-preflight.mjs +52 -37
  179. package/scripts/codex-mcp-gateway-sync.mjs +59 -39
  180. package/scripts/completions/tfx.bash +47 -47
  181. package/scripts/completions/tfx.fish +44 -44
  182. package/scripts/completions/tfx.zsh +83 -83
  183. package/scripts/config-audit.mjs +232 -0
  184. package/scripts/convert-to-tmpl.mjs +54 -0
  185. package/scripts/cross-review-gate.mjs +35 -12
  186. package/scripts/cross-review-tracker.mjs +21 -8
  187. package/scripts/demo.mjs +35 -17
  188. package/scripts/doctor-diagnose.mjs +284 -0
  189. package/scripts/gen-skill-docs.mjs +7 -2
  190. package/scripts/gen-skill-manifest.mjs +2 -1
  191. package/scripts/headless-guard.mjs +86 -48
  192. package/scripts/hub-ensure.mjs +45 -26
  193. package/scripts/keyword-detector.mjs +41 -20
  194. package/scripts/keyword-rules-expander.mjs +47 -30
  195. package/scripts/lib/claudemd-scanner.mjs +6 -1
  196. package/scripts/lib/context.mjs +3 -3
  197. package/scripts/lib/cross-review-utils.mjs +6 -3
  198. package/scripts/lib/env-probe.mjs +47 -28
  199. package/scripts/lib/gemini-profiles.mjs +44 -10
  200. package/scripts/lib/handoff.mjs +33 -17
  201. package/scripts/lib/hook-utils.mjs +8 -6
  202. package/scripts/lib/keyword-rules.mjs +43 -19
  203. package/scripts/lib/logger.mjs +24 -24
  204. package/scripts/lib/mcp-filter.mjs +377 -239
  205. package/scripts/lib/mcp-guard-engine.mjs +194 -79
  206. package/scripts/lib/mcp-manifest.mjs +23 -13
  207. package/scripts/lib/mcp-server-catalog.mjs +300 -63
  208. package/scripts/lib/psmux-info.mjs +11 -6
  209. package/scripts/lib/remote-spawn-transfer.mjs +44 -14
  210. package/scripts/lib/skill-template.mjs +30 -7
  211. package/scripts/mcp-check.mjs +58 -39
  212. package/scripts/mcp-gateway-config.mjs +83 -39
  213. package/scripts/mcp-gateway-ensure.mjs +43 -35
  214. package/scripts/mcp-gateway-integration-test.mjs +70 -58
  215. package/scripts/mcp-gateway-start.mjs +126 -60
  216. package/scripts/mcp-gateway-verify.mjs +24 -22
  217. package/scripts/mcp-safety-guard.mjs +44 -11
  218. package/scripts/notion-read.mjs +199 -84
  219. package/scripts/pack.mjs +94 -89
  220. package/scripts/preflight-cache.mjs +27 -10
  221. package/scripts/preinstall.mjs +42 -13
  222. package/scripts/remote-spawn.mjs +309 -94
  223. package/scripts/run.cjs +8 -5
  224. package/scripts/session-spawn-helper.mjs +130 -39
  225. package/scripts/session-stale-cleanup.mjs +123 -0
  226. package/scripts/setup.mjs +941 -492
  227. package/scripts/test-lock.mjs +20 -7
  228. package/scripts/test-tfx-route-no-claude-native.mjs +16 -12
  229. package/scripts/tfx-batch-stats.mjs +32 -11
  230. package/scripts/tfx-gate-activate.mjs +11 -4
  231. package/scripts/tfx-route-post.mjs +87 -20
  232. package/scripts/tfx-route-worker.mjs +57 -51
  233. package/scripts/tfx-route.sh +41 -124
  234. package/scripts/tmp-cleanup.mjs +21 -7
  235. package/scripts/token-snapshot.mjs +204 -85
  236. package/skills/.omc/state/agent-replay-8f0e10a9-9693-4410-96f5-a6b07e8ed995.jsonl +1 -0
  237. package/skills/.omc/state/idle-notif-cooldown.json +3 -0
  238. package/skills/.omc/state/last-tool-error.json +7 -0
  239. package/skills/.omc/state/subagent-tracking.json +7 -0
  240. package/skills/_templates/base.md +1 -6
  241. package/skills/merge-worktree/SKILL.md.tmpl +144 -0
  242. package/skills/shared/telemetry-segment.md +6 -0
  243. package/skills/star-prompt/SKILL.md.tmpl +222 -0
  244. package/skills/tfx-analysis/SKILL.md.tmpl +107 -0
  245. package/skills/tfx-analysis/skill.json +1 -6
  246. package/skills/tfx-auto/SKILL.md +1 -0
  247. package/skills/tfx-auto-codex/SKILL.md.tmpl +106 -0
  248. package/skills/tfx-auto-codex/skill.json +1 -3
  249. package/skills/tfx-autopilot/SKILL.md.tmpl +116 -0
  250. package/skills/tfx-autopilot/skill.json +1 -5
  251. package/skills/tfx-autoresearch/SKILL.md.tmpl +136 -0
  252. package/skills/tfx-autoroute/SKILL.md.tmpl +189 -0
  253. package/skills/tfx-autoroute/skill.json +1 -7
  254. package/skills/tfx-codex/SKILL.md +1 -0
  255. package/skills/tfx-codex/skill.json +1 -3
  256. package/skills/tfx-codex-swarm/SKILL.md.tmpl +16 -0
  257. package/skills/tfx-codex-swarm/evals/evals.json +1 -1
  258. package/skills/tfx-codex-swarm/skill.json +1 -4
  259. package/skills/tfx-codex-swarm-workspace/iteration-1/benchmark.json +54 -12
  260. package/skills/tfx-codex-swarm-workspace/iteration-1/full-swarm-all-prds/with_skill/grading.json +35 -7
  261. package/skills/tfx-codex-swarm-workspace/iteration-1/full-swarm-all-prds/without_skill/grading.json +35 -7
  262. package/skills/tfx-codex-swarm-workspace/iteration-1/implicit-swarm-no-keywords/with_skill/grading.json +25 -5
  263. package/skills/tfx-codex-swarm-workspace/iteration-1/implicit-swarm-no-keywords/without_skill/grading.json +25 -5
  264. package/skills/tfx-codex-swarm-workspace/iteration-1/selective-spawn-with-override/with_skill/grading.json +20 -4
  265. package/skills/tfx-codex-swarm-workspace/iteration-1/selective-spawn-with-override/without_skill/grading.json +16 -4
  266. package/skills/tfx-consensus/SKILL.md.tmpl +146 -0
  267. package/skills/tfx-debate/SKILL.md.tmpl +192 -0
  268. package/skills/tfx-debate/skill.json +1 -7
  269. package/skills/tfx-deep-analysis/SKILL.md.tmpl +228 -0
  270. package/skills/tfx-deep-analysis/skill.json +1 -5
  271. package/skills/tfx-deep-interview/SKILL.md.tmpl +203 -0
  272. package/skills/tfx-deep-plan/SKILL.md.tmpl +282 -0
  273. package/skills/tfx-deep-qa/SKILL.md.tmpl +165 -0
  274. package/skills/tfx-deep-qa/skill.json +1 -6
  275. package/skills/tfx-deep-research/SKILL.md.tmpl +217 -0
  276. package/skills/tfx-deep-review/SKILL.md.tmpl +179 -0
  277. package/skills/tfx-doctor/SKILL.md +21 -0
  278. package/skills/tfx-doctor/SKILL.md.tmpl +172 -0
  279. package/skills/tfx-doctor/skill.json +1 -3
  280. package/skills/tfx-find/SKILL.md +1 -0
  281. package/skills/tfx-forge/SKILL.md.tmpl +187 -0
  282. package/skills/tfx-fullcycle/SKILL.md.tmpl +286 -0
  283. package/skills/tfx-fullcycle/skill.json +1 -6
  284. package/skills/tfx-gemini/SKILL.md.tmpl +91 -0
  285. package/skills/tfx-gemini/skill.json +1 -3
  286. package/skills/tfx-hooks/SKILL.md.tmpl +216 -0
  287. package/skills/tfx-hooks/skill.json +1 -3
  288. package/skills/tfx-hub/SKILL.md.tmpl +212 -0
  289. package/skills/tfx-hub/skill.json +1 -3
  290. package/skills/tfx-index/SKILL.md +1 -0
  291. package/skills/tfx-index/skill.json +1 -6
  292. package/skills/tfx-interview/SKILL.md.tmpl +285 -0
  293. package/skills/tfx-multi/SKILL.md.tmpl +183 -0
  294. package/skills/tfx-multi/skill.json +1 -3
  295. package/skills/tfx-panel/SKILL.md.tmpl +189 -0
  296. package/skills/tfx-panel/skill.json +1 -7
  297. package/skills/tfx-persist/SKILL.md.tmpl +270 -0
  298. package/skills/tfx-persist/skill.json +1 -7
  299. package/skills/tfx-plan/SKILL.md +1 -0
  300. package/skills/tfx-plan/skill.json +1 -6
  301. package/skills/tfx-profile/SKILL.md.tmpl +239 -0
  302. package/skills/tfx-profile/skill.json +1 -3
  303. package/skills/tfx-prune/SKILL.md.tmpl +200 -0
  304. package/skills/tfx-prune/skill.json +1 -7
  305. package/skills/tfx-psmux-rules/SKILL.md.tmpl +326 -0
  306. package/skills/tfx-psmux-rules/skill.json +1 -4
  307. package/skills/tfx-qa/SKILL.md +1 -0
  308. package/skills/tfx-qa/skill.json +1 -6
  309. package/skills/tfx-ralph/SKILL.md.tmpl +28 -0
  310. package/skills/tfx-ralph/skill.json +1 -4
  311. package/skills/tfx-remote-setup/SKILL.md.tmpl +576 -0
  312. package/skills/tfx-remote-setup/skill.json +1 -3
  313. package/skills/tfx-remote-spawn/SKILL.md.tmpl +263 -0
  314. package/skills/tfx-remote-spawn/references/hosts.json +16 -0
  315. package/skills/tfx-remote-spawn/skill.json +1 -4
  316. package/skills/tfx-research/SKILL.md +1 -0
  317. package/skills/tfx-review/SKILL.md +1 -0
  318. package/skills/tfx-review/skill.json +1 -6
  319. package/skills/tfx-setup/SKILL.md.tmpl +504 -0
  320. package/skills/tfx-setup/skill.json +1 -3
  321. package/skills/tfx-swarm/SKILL.md +22 -0
  322. package/skills/tfx-swarm/SKILL.md.tmpl +218 -0
  323. package/tui/codex-profile.mjs +88 -33
  324. package/tui/core.mjs +45 -15
  325. package/tui/doctor.mjs +75 -28
  326. package/tui/gemini-profile.mjs +74 -29
  327. package/tui/monitor-data.mjs +8 -4
  328. package/tui/monitor.mjs +71 -27
  329. package/tui/setup.mjs +133 -42
package/bin/triflux.mjs CHANGED
@@ -1,19 +1,49 @@
1
1
  #!/usr/bin/env node
2
+ import { setTimeout as delay } from "node:timers/promises";
3
+ import { execFileSync, execSync, spawn } from "child_process";
2
4
  // triflux CLI — setup, doctor, version
3
- import { copyFileSync, existsSync, readFileSync, readSync, writeFileSync, mkdirSync, chmodSync, readdirSync, unlinkSync, statSync, openSync, closeSync } from "fs";
4
- import { join, dirname, basename, resolve } from "path";
5
+ import {
6
+ chmodSync,
7
+ closeSync,
8
+ copyFileSync,
9
+ existsSync,
10
+ mkdirSync,
11
+ openSync,
12
+ readdirSync,
13
+ readFileSync,
14
+ readSync,
15
+ statSync,
16
+ unlinkSync,
17
+ writeFileSync,
18
+ } from "fs";
5
19
  import { homedir, tmpdir } from "os";
6
- import { execSync, execFileSync, spawn } from "child_process";
20
+ import { basename, dirname, join, resolve } from "path";
7
21
  import { fileURLToPath } from "url";
8
- import { setTimeout as delay } from "node:timers/promises";
9
22
  import { loadDelegatorSchemaBundle } from "../hub/delegator/tool-definitions.mjs";
10
- import { detectMultiplexer, getSessionAttachedCount, killSession, listSessions, tmuxExec } from "../hub/team/session.mjs";
11
- import { forceCleanupTeam } from "../hub/team/nativeProxy.mjs";
12
- import { cleanupStaleOmcTeams, inspectStaleOmcTeams } from "../hub/team/staleState.mjs";
23
+ import {
24
+ checkNetworkAvailability,
25
+ validateRuntimeCachePaths,
26
+ } from "../hub/lib/cache-guard.mjs";
13
27
  import { getPipelineStateDbPath } from "../hub/pipeline/state.mjs";
14
- import { serializeHandoff } from "../scripts/lib/handoff.mjs";
28
+ import { forceCleanupTeam } from "../hub/team/nativeProxy.mjs";
29
+ import {
30
+ detectMultiplexer,
31
+ getSessionAttachedCount,
32
+ killSession,
33
+ listSessions,
34
+ tmuxExec,
35
+ } from "../hub/team/session.mjs";
36
+ import {
37
+ cleanupStaleOmcTeams,
38
+ inspectStaleOmcTeams,
39
+ } from "../hub/team/staleState.mjs";
40
+ import {
41
+ ensureGlobalClaudeRoutingSection,
42
+ ensureTfxSection,
43
+ getLatestRoutingTable,
44
+ } from "../scripts/claudemd-sync.mjs";
15
45
  import { ensureGeminiProfiles } from "../scripts/lib/gemini-profiles.mjs";
16
- import { probePsmuxSupport, formatPsmuxInstallGuidance, formatPsmuxUpdateGuidance } from "../scripts/lib/psmux-info.mjs";
46
+ import { serializeHandoff } from "../scripts/lib/handoff.mjs";
17
47
  import {
18
48
  addRegistryServer,
19
49
  createDefaultRegistry,
@@ -25,19 +55,27 @@ import {
25
55
  syncRegistryTargets,
26
56
  } from "../scripts/lib/mcp-guard-engine.mjs";
27
57
  import {
28
- SYNC_MAP, SKILL_ALIASES, REQUIRED_CODEX_PROFILES, LEGACY_CODEX_MODELS,
29
- syncAliasedSkillDir, hasProfileSection, replaceProfileSection,
30
- ensureCodexProfiles, getVersion, cleanupStaleSkills,
31
- extractManagedHookFilename, getManagedRegistryHooks, ensureHooksInSettings,
58
+ formatPsmuxInstallGuidance,
59
+ formatPsmuxUpdateGuidance,
60
+ probePsmuxSupport,
61
+ } from "../scripts/lib/psmux-info.mjs";
62
+ import {
63
+ cleanupStaleSkills,
32
64
  ensureCodexHubServerConfig,
65
+ ensureCodexProfiles,
66
+ ensureHooksInSettings,
67
+ extractManagedHookFilename,
68
+ getManagedRegistryHooks,
69
+ getVersion,
70
+ hasProfileSection,
71
+ LEGACY_CODEX_MODELS,
72
+ REQUIRED_CODEX_PROFILES,
73
+ replaceProfileSection,
74
+ SKILL_ALIASES,
75
+ SYNC_MAP,
76
+ syncAliasedSkillDir,
33
77
  } from "../scripts/setup.mjs";
34
- import {
35
- ensureGlobalClaudeRoutingSection,
36
- ensureTfxSection,
37
- getLatestRoutingTable,
38
- } from "../scripts/claudemd-sync.mjs";
39
78
  import { cleanupTmpFiles } from "../scripts/tmp-cleanup.mjs";
40
- import { checkNetworkAvailability, validateRuntimeCachePaths } from "../hub/lib/cache-guard.mjs";
41
79
 
42
80
  const PKG_ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
43
81
  const CLAUDE_DIR = join(homedir(), ".claude");
@@ -48,7 +86,6 @@ const PKG = JSON.parse(readFileSync(join(PKG_ROOT, "package.json"), "utf8"));
48
86
  // 이 배열에 포함된 버전에서만 star prompt를 표시한다 (빈 배열 = 모든 버전에서 표시)
49
87
  const STAR_PROMPT_VERSIONS = [];
50
88
 
51
-
52
89
  // ── 색상 체계 (triflux brand: amber/orange accent) ──
53
90
  const CYAN = "\x1b[36m";
54
91
  const GREEN = "\x1b[32m";
@@ -88,48 +125,107 @@ const CLI_COMMAND_SCHEMAS = Object.freeze({
88
125
  usage: "tfx setup [--dry-run]",
89
126
  description: "파일 동기화 + HUD/MCP 설정",
90
127
  options: [
91
- { name: "--dry-run", type: "boolean", description: "실제 변경 없이 예정 작업을 JSON으로 출력" },
128
+ {
129
+ name: "--dry-run",
130
+ type: "boolean",
131
+ description: "실제 변경 없이 예정 작업을 JSON으로 출력",
132
+ },
92
133
  ],
93
134
  },
94
135
  doctor: {
95
- usage: "tfx doctor [--fix] [--reset] [--json]",
136
+ usage: "tfx doctor [--fix] [--reset] [--audit] [--diagnose] [--json]",
96
137
  description: "설치 상태 진단 및 자동 복구",
97
138
  options: [
98
- { name: "--fix", type: "boolean", description: "파일/캐시 자동 복구 후 재진단" },
99
- { name: "--reset", type: "boolean", description: "캐시 초기화 후 재생성" },
100
- { name: "--json", type: "boolean", description: "구조화된 진단 결과 JSON 출력" },
139
+ {
140
+ name: "--fix",
141
+ type: "boolean",
142
+ description: "파일/캐시 자동 복구 후 재진단",
143
+ },
144
+ {
145
+ name: "--reset",
146
+ type: "boolean",
147
+ description: "캐시 초기화 후 재생성",
148
+ },
149
+ {
150
+ name: "--audit",
151
+ type: "boolean",
152
+ description: "설정 보안/성능 정적 감사",
153
+ },
154
+ {
155
+ name: "--diagnose",
156
+ type: "boolean",
157
+ description: "진단 번들(zip) 생성: spawn-trace + hook timing + system info",
158
+ },
159
+ {
160
+ name: "--json",
161
+ type: "boolean",
162
+ description: "구조화된 진단 결과 JSON 출력",
163
+ },
101
164
  ],
102
165
  },
103
166
  version: {
104
167
  usage: "tfx version [--json]",
105
168
  description: "triflux 및 동기화된 스크립트 버전 표시",
106
169
  options: [
107
- { name: "--json", type: "boolean", description: "버전 정보를 JSON으로 출력" },
170
+ {
171
+ name: "--json",
172
+ type: "boolean",
173
+ description: "버전 정보를 JSON으로 출력",
174
+ },
108
175
  ],
109
176
  },
110
177
  handoff: {
111
- usage: "tfx handoff [--target local|remote] [--decision <text>] [--decision-file <path>] [--output <path>] [--json]",
178
+ usage:
179
+ "tfx handoff [--target local|remote] [--decision <text>] [--decision-file <path>] [--output <path>] [--json]",
112
180
  description: "현재 작업 컨텍스트를 세션 핸드오프 프롬프트로 직렬화",
113
181
  options: [
114
- { name: "--target", type: "string", description: "주입 대상 (local|remote, 기본값 remote)" },
115
- { name: "--decision", type: "string", description: "핸드오프 결정사항 (반복 지정 가능)" },
116
- { name: "--decision-file", type: "string", description: "결정사항 파일 (라인/불릿 단위)" },
117
- { name: "--output", type: "string", description: "생성한 핸드오프 프롬프트 저장 경로" },
118
- { name: "--json", type: "boolean", description: "핸드오프 결과를 JSON으로 출력" },
182
+ {
183
+ name: "--target",
184
+ type: "string",
185
+ description: "주입 대상 (local|remote, 기본값 remote)",
186
+ },
187
+ {
188
+ name: "--decision",
189
+ type: "string",
190
+ description: "핸드오프 결정사항 (반복 지정 가능)",
191
+ },
192
+ {
193
+ name: "--decision-file",
194
+ type: "string",
195
+ description: "결정사항 파일 (라인/불릿 단위)",
196
+ },
197
+ {
198
+ name: "--output",
199
+ type: "string",
200
+ description: "생성한 핸드오프 프롬프트 저장 경로",
201
+ },
202
+ {
203
+ name: "--json",
204
+ type: "boolean",
205
+ description: "핸드오프 결과를 JSON으로 출력",
206
+ },
119
207
  ],
120
208
  },
121
209
  list: {
122
210
  usage: "tfx list [--json]",
123
211
  description: "패키지 스킬과 사용자 스킬 목록 표시",
124
212
  options: [
125
- { name: "--json", type: "boolean", description: "스킬 목록을 JSON으로 출력" },
213
+ {
214
+ name: "--json",
215
+ type: "boolean",
216
+ description: "스킬 목록을 JSON으로 출력",
217
+ },
126
218
  ],
127
219
  },
128
220
  schema: {
129
221
  usage: "tfx schema [command-or-tool]",
130
222
  description: "CLI 커맨드 파라미터와 Hub delegator schema 번들 출력",
131
223
  options: [
132
- { name: "command-or-tool", type: "string", description: "예: doctor, setup, delegate, delegate-reply, status" },
224
+ {
225
+ name: "command-or-tool",
226
+ type: "string",
227
+ description: "예: doctor, setup, delegate, delegate-reply, status",
228
+ },
133
229
  ],
134
230
  },
135
231
  hooks: {
@@ -141,7 +237,8 @@ const CLI_COMMAND_SCHEMAS = Object.freeze({
141
237
  apply: "오케스트레이터 적용 (settings.json 통합)",
142
238
  restore: "원래 settings.json 훅 복원",
143
239
  status: "오케스트레이터 적용 상태 확인",
144
- "set-priority": "특정 훅 우선순위 변경: hooks set-priority <hookId> <priority>",
240
+ "set-priority":
241
+ "특정 훅 우선순위 변경: hooks set-priority <hookId> <priority>",
145
242
  toggle: "특정 훅 활성/비활성 토글: hooks toggle <hookId>",
146
243
  },
147
244
  },
@@ -151,22 +248,44 @@ const CLI_COMMAND_SCHEMAS = Object.freeze({
151
248
  subcommands: {
152
249
  list: {
153
250
  usage: "tfx mcp list [--json]",
154
- options: [{ name: "--json", type: "boolean", description: "registry + 실제 설정 상태를 JSON으로 출력" }],
251
+ options: [
252
+ {
253
+ name: "--json",
254
+ type: "boolean",
255
+ description: "registry + 실제 설정 상태를 JSON으로 출력",
256
+ },
257
+ ],
155
258
  },
156
259
  sync: {
157
260
  usage: "tfx mcp sync [--json]",
158
- options: [{ name: "--json", type: "boolean", description: "동기화 결과를 JSON으로 출력" }],
261
+ options: [
262
+ {
263
+ name: "--json",
264
+ type: "boolean",
265
+ description: "동기화 결과를 JSON으로 출력",
266
+ },
267
+ ],
159
268
  },
160
269
  add: {
161
270
  usage: "tfx mcp add <name> --url <url> [--json]",
162
271
  options: [
163
272
  { name: "--url", type: "string", description: "등록할 MCP URL" },
164
- { name: "--json", type: "boolean", description: "등록 결과를 JSON으로 출력" },
273
+ {
274
+ name: "--json",
275
+ type: "boolean",
276
+ description: "등록 결과를 JSON으로 출력",
277
+ },
165
278
  ],
166
279
  },
167
280
  remove: {
168
281
  usage: "tfx mcp remove <name> [--json]",
169
- options: [{ name: "--json", type: "boolean", description: "제거 결과를 JSON으로 출력" }],
282
+ options: [
283
+ {
284
+ name: "--json",
285
+ type: "boolean",
286
+ description: "제거 결과를 JSON으로 출력",
287
+ },
288
+ ],
170
289
  },
171
290
  },
172
291
  },
@@ -176,25 +295,54 @@ const CLI_COMMAND_SCHEMAS = Object.freeze({
176
295
  subcommands: {
177
296
  start: { usage: "tfx hub start [--port N]" },
178
297
  stop: { usage: "tfx hub stop" },
179
- ensure: { usage: "tfx hub ensure [--port N] [--json]", description: "헬스체크 + 자동 시작 (idempotent)" },
298
+ ensure: {
299
+ usage: "tfx hub ensure [--port N] [--json]",
300
+ description: "헬스체크 + 자동 시작 (idempotent)",
301
+ },
180
302
  status: {
181
303
  usage: "tfx hub status [--json]",
182
- options: [{ name: "--json", type: "boolean", description: "허브 상태를 JSON으로 출력" }],
304
+ options: [
305
+ {
306
+ name: "--json",
307
+ type: "boolean",
308
+ description: "허브 상태를 JSON으로 출력",
309
+ },
310
+ ],
183
311
  },
184
312
  },
185
313
  },
186
314
  multi: {
187
- usage: "tfx multi [--dashboard-layout lite|single|split-2col|split-3col|auto] <subcommand|task>",
315
+ usage:
316
+ "tfx multi [--dashboard-layout lite|single|split-2col|split-3col|auto] <subcommand|task>",
188
317
  description: "멀티-CLI 팀 모드",
189
318
  options: [
190
- { name: "--dashboard", type: "boolean", description: "headless dashboard viewer 표시 (기본값: 켜짐)" },
191
- { name: "--no-dashboard", type: "boolean", description: "headless dashboard viewer 비활성화" },
192
- { name: "--dashboard-layout", type: "string", description: "dashboard viewer 레이아웃 선택: lite|single|split-2col|split-3col|auto" },
319
+ {
320
+ name: "--dashboard",
321
+ type: "boolean",
322
+ description: "headless dashboard viewer 표시 (기본값: 켜짐)",
323
+ },
324
+ {
325
+ name: "--no-dashboard",
326
+ type: "boolean",
327
+ description: "headless dashboard viewer 비활성화",
328
+ },
329
+ {
330
+ name: "--dashboard-layout",
331
+ type: "string",
332
+ description:
333
+ "dashboard viewer 레이아웃 선택: lite|single|split-2col|split-3col|auto",
334
+ },
193
335
  ],
194
336
  subcommands: {
195
337
  status: {
196
338
  usage: "tfx multi status [--json]",
197
- options: [{ name: "--json", type: "boolean", description: "팀 상태를 JSON으로 출력" }],
339
+ options: [
340
+ {
341
+ name: "--json",
342
+ type: "boolean",
343
+ description: "팀 상태를 JSON으로 출력",
344
+ },
345
+ ],
198
346
  },
199
347
  },
200
348
  },
@@ -203,13 +351,27 @@ const CLI_COMMAND_SCHEMAS = Object.freeze({
203
351
  // ── 유틸리티 ──
204
352
  // ok/warn/fail/info/section 의 console.log는 디버그 로그가 아닌 의도된 CLI 출력입니다.
205
353
 
206
- function ok(msg) { console.log(` ${GREEN_BRIGHT}✓${RESET} ${msg}`); }
207
- function warn(msg) { console.log(` ${YELLOW}⚠${RESET} ${msg}`); }
208
- function fail(msg) { console.log(` ${RED_BRIGHT}✗${RESET} ${msg}`); }
209
- function info(msg) { console.log(` ${GRAY}${msg}${RESET}`); }
210
- function section(title) { console.log(`\n ${AMBER}▸${RESET} ${BOLD}${title}${RESET}`); }
211
- function stripAnsi(value) { return String(value ?? "").replace(ANSI_PATTERN, ""); }
212
- function printJson(payload) { process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`); }
354
+ function ok(msg) {
355
+ console.log(` ${GREEN_BRIGHT}✓${RESET} ${msg}`);
356
+ }
357
+ function warn(msg) {
358
+ console.log(` ${YELLOW}⚠${RESET} ${msg}`);
359
+ }
360
+ function fail(msg) {
361
+ console.log(` ${RED_BRIGHT}✗${RESET} ${msg}`);
362
+ }
363
+ function info(msg) {
364
+ console.log(` ${GRAY}${msg}${RESET}`);
365
+ }
366
+ function section(title) {
367
+ console.log(`\n ${AMBER}▸${RESET} ${BOLD}${title}${RESET}`);
368
+ }
369
+ function stripAnsi(value) {
370
+ return String(value ?? "").replace(ANSI_PATTERN, "");
371
+ }
372
+ function printJson(payload) {
373
+ process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`);
374
+ }
213
375
 
214
376
  function withConsoleSilenced(enabled, fn) {
215
377
  if (!enabled) return fn();
@@ -225,12 +387,10 @@ function withConsoleSilenced(enabled, fn) {
225
387
  }
226
388
  }
227
389
 
228
- function createCliError(message, {
229
- exitCode = EXIT_ERROR,
230
- reason = "error",
231
- fix = null,
232
- cause = null,
233
- } = {}) {
390
+ function createCliError(
391
+ message,
392
+ { exitCode = EXIT_ERROR, reason = "error", fix = null, cause = null } = {},
393
+ ) {
234
394
  const error = new Error(message);
235
395
  error.exitCode = exitCode;
236
396
  error.reason = reason;
@@ -257,9 +417,12 @@ function inferReason(error, exitCode) {
257
417
  function inferFix(error, exitCode) {
258
418
  if (typeof error?.fix === "string" && error.fix) return error.fix;
259
419
  if (exitCode === EXIT_ARG_ERROR) return "tfx --help";
260
- if (exitCode === EXIT_CLI_MISSING) return "필수 CLI를 설치한 뒤 `tfx doctor`로 상태를 다시 확인하세요.";
261
- if (exitCode === EXIT_HUB_ERROR) return "`tfx hub start`로 허브를 다시 시작하거나 설치 상태를 확인하세요.";
262
- if (exitCode === EXIT_CONFIG_ERROR) return "설정 파일 JSON/TOML 문법을 수정한 뒤 다시 실행하세요.";
420
+ if (exitCode === EXIT_CLI_MISSING)
421
+ return "필수 CLI를 설치한 `tfx doctor`로 상태를 다시 확인하세요.";
422
+ if (exitCode === EXIT_HUB_ERROR)
423
+ return "`tfx hub start`로 허브를 다시 시작하거나 설치 상태를 확인하세요.";
424
+ if (exitCode === EXIT_CONFIG_ERROR)
425
+ return "설정 파일 JSON/TOML 문법을 수정한 뒤 다시 실행하세요.";
263
426
  return null;
264
427
  }
265
428
 
@@ -288,7 +451,11 @@ function handleFatalError(error, { json = false } = {}) {
288
451
  function renderErrorMessage(message, fallback = "unknown error") {
289
452
  if (typeof message === "string") {
290
453
  const normalized = message.trim().toLowerCase();
291
- if (normalized.length > 0 && normalized !== "undefined" && normalized !== "null") {
454
+ if (
455
+ normalized.length > 0 &&
456
+ normalized !== "undefined" &&
457
+ normalized !== "null"
458
+ ) {
292
459
  return message.trim();
293
460
  }
294
461
  }
@@ -297,18 +464,40 @@ function renderErrorMessage(message, fallback = "unknown error") {
297
464
 
298
465
  function which(cmd) {
299
466
  try {
300
- const result = process.platform === "win32"
301
- ? execFileSync("where", [cmd], { encoding: "utf8", timeout: 5000, stdio: ["pipe", "pipe", "ignore"], windowsHide: true })
302
- : execFileSync("which", [cmd], { encoding: "utf8", timeout: 5000, stdio: ["pipe", "pipe", "ignore"] });
467
+ const result =
468
+ process.platform === "win32"
469
+ ? execFileSync("where", [cmd], {
470
+ encoding: "utf8",
471
+ timeout: 5000,
472
+ stdio: ["pipe", "pipe", "ignore"],
473
+ windowsHide: true,
474
+ })
475
+ : execFileSync("which", [cmd], {
476
+ encoding: "utf8",
477
+ timeout: 5000,
478
+ stdio: ["pipe", "pipe", "ignore"],
479
+ });
303
480
  return result.trim().split(/\r?\n/)[0] || null;
304
- } catch { return null; }
481
+ } catch {
482
+ return null;
483
+ }
305
484
  }
306
485
 
307
486
  function whichInShell(cmd, shell) {
308
487
  const shellArgs = {
309
- bash: ["bash", ["-c", `source ~/.bashrc 2>/dev/null && command -v "${cmd}" 2>/dev/null`]],
488
+ bash: [
489
+ "bash",
490
+ ["-c", `source ~/.bashrc 2>/dev/null && command -v "${cmd}" 2>/dev/null`],
491
+ ],
310
492
  cmd: ["cmd", ["/c", "where", cmd]],
311
- pwsh: ["pwsh", ["-NoProfile", "-c", `(Get-Command '${cmd.replace(/'/g, "''")}' -EA SilentlyContinue).Source`]],
493
+ pwsh: [
494
+ "pwsh",
495
+ [
496
+ "-NoProfile",
497
+ "-c",
498
+ `(Get-Command '${cmd.replace(/'/g, "''")}' -EA SilentlyContinue).Source`,
499
+ ],
500
+ ],
312
501
  };
313
502
  const entry = shellArgs[shell];
314
503
  if (!entry) return null;
@@ -320,22 +509,36 @@ function whichInShell(cmd, shell) {
320
509
  windowsHide: true,
321
510
  }).trim();
322
511
  return result.split(/\r?\n/)[0] || null;
323
- } catch { return null; }
512
+ } catch {
513
+ return null;
514
+ }
324
515
  }
325
516
 
326
517
  function isDevUpdateRequested(argv = process.argv) {
327
- return argv.includes("--dev") || argv.includes("@dev") || argv.includes("dev");
518
+ return (
519
+ argv.includes("--dev") || argv.includes("@dev") || argv.includes("dev")
520
+ );
328
521
  }
329
522
 
330
523
  function checkShellAvailable(shell) {
331
- const cmds = { bash: "bash --version", cmd: "cmd /c echo ok", pwsh: "pwsh -NoProfile -c echo ok" };
524
+ const cmds = {
525
+ bash: "bash --version",
526
+ cmd: "cmd /c echo ok",
527
+ pwsh: "pwsh -NoProfile -c echo ok",
528
+ };
332
529
  try {
333
- execSync(cmds[shell], { encoding: "utf8", timeout: 5000, stdio: ["pipe", "pipe", "ignore"], windowsHide: true });
530
+ execSync(cmds[shell], {
531
+ encoding: "utf8",
532
+ timeout: 5000,
533
+ stdio: ["pipe", "pipe", "ignore"],
534
+ windowsHide: true,
535
+ });
334
536
  return true;
335
- } catch { return false; }
537
+ } catch {
538
+ return false;
539
+ }
336
540
  }
337
541
 
338
-
339
542
  function parseSessionCreated(rawValue) {
340
543
  const value = String(rawValue || "").trim();
341
544
  if (!value) return null;
@@ -350,7 +553,10 @@ function parseSessionCreated(rawValue) {
350
553
  return Math.floor(parsed / 1000);
351
554
  }
352
555
 
353
- const normalized = value.replace(/^(\d{2})-(\d{2})-(\d{2})(\s+)/, "20$1-$2-$3$4");
556
+ const normalized = value.replace(
557
+ /^(\d{2})-(\d{2})-(\d{2})(\s+)/,
558
+ "20$1-$2-$3$4",
559
+ );
354
560
  const reparsed = Date.parse(normalized);
355
561
  if (Number.isFinite(reparsed)) {
356
562
  return Math.floor(reparsed / 1000);
@@ -371,7 +577,9 @@ function readTeamSessionCreatedMap() {
371
577
  const createdMap = new Map();
372
578
 
373
579
  try {
374
- const output = tmuxExec('list-sessions -F "#{session_name} #{session_created}"');
580
+ const output = tmuxExec(
581
+ 'list-sessions -F "#{session_name} #{session_created}"',
582
+ );
375
583
  for (const line of output.split(/\r?\n/)) {
376
584
  const trimmed = line.trim();
377
585
  if (!trimmed) continue;
@@ -408,10 +616,17 @@ function inspectTeamSessions() {
408
616
  const createdMap = readTeamSessionCreatedMap();
409
617
  const nowSec = Math.floor(Date.now() / 1000);
410
618
  const sessions = sessionNames.map((sessionName) => {
411
- const createdInfo = createdMap.get(sessionName) || { createdAt: null, createdRaw: "" };
619
+ const createdInfo = createdMap.get(sessionName) || {
620
+ createdAt: null,
621
+ createdRaw: "",
622
+ };
412
623
  const attachedCount = getSessionAttachedCount(sessionName);
413
- const ageSec = createdInfo.createdAt == null ? null : Math.max(0, nowSec - createdInfo.createdAt);
414
- const stale = ageSec != null && ageSec >= STALE_TEAM_MAX_AGE_SEC && attachedCount === 0;
624
+ const ageSec =
625
+ createdInfo.createdAt == null
626
+ ? null
627
+ : Math.max(0, nowSec - createdInfo.createdAt);
628
+ const stale =
629
+ ageSec != null && ageSec >= STALE_TEAM_MAX_AGE_SEC && attachedCount === 0;
415
630
 
416
631
  return {
417
632
  sessionName,
@@ -459,7 +674,6 @@ async function cleanupStaleTeamSessions(staleSessions) {
459
674
  return { cleaned, failed };
460
675
  }
461
676
 
462
-
463
677
  function previewCodexProfiles() {
464
678
  const original = existsSync(CODEX_CONFIG_PATH)
465
679
  ? readFileSync(CODEX_CONFIG_PATH, "utf8")
@@ -481,13 +695,19 @@ function previewCodexProfiles() {
481
695
  }
482
696
  }
483
697
 
484
- const windowsSandbox = process.platform === "win32" && !updated.includes("[windows]");
698
+ const windowsSandbox =
699
+ process.platform === "win32" && !updated.includes("[windows]");
485
700
 
486
701
  return {
487
702
  path: CODEX_CONFIG_PATH,
488
703
  profiles,
489
704
  windowsSandbox,
490
- change: profiles.length > 0 || windowsSandbox ? (original ? "update" : "create") : "noop",
705
+ change:
706
+ profiles.length > 0 || windowsSandbox
707
+ ? original
708
+ ? "update"
709
+ : "create"
710
+ : "noop",
491
711
  };
492
712
  }
493
713
 
@@ -505,7 +725,9 @@ function syncFile(src, dst, label) {
505
725
 
506
726
  if (!existsSync(dst)) {
507
727
  copyFileSync(src, dst);
508
- try { chmodSync(dst, 0o755); } catch {}
728
+ try {
729
+ chmodSync(dst, 0o755);
730
+ } catch {}
509
731
  ok(`${label}: 설치됨 ${srcVer ? `(v${srcVer})` : ""}`);
510
732
  return true;
511
733
  }
@@ -514,10 +736,15 @@ function syncFile(src, dst, label) {
514
736
  const dstContent = readFileSync(dst, "utf8");
515
737
  if (srcContent !== dstContent) {
516
738
  copyFileSync(src, dst);
517
- try { chmodSync(dst, 0o755); } catch {}
518
- const verInfo = (srcVer && dstVer && srcVer !== dstVer)
519
- ? `(v${dstVer} v${srcVer})`
520
- : srcVer ? `(v${srcVer}, 내용 변경)` : "(내용 변경)";
739
+ try {
740
+ chmodSync(dst, 0o755);
741
+ } catch {}
742
+ const verInfo =
743
+ srcVer && dstVer && srcVer !== dstVer
744
+ ? `(v${dstVer} → v${srcVer})`
745
+ : srcVer
746
+ ? `(v${srcVer}, 내용 변경)`
747
+ : "(내용 변경)";
521
748
  ok(`${label}: 업데이트됨 ${verInfo}`);
522
749
  return true;
523
750
  }
@@ -561,22 +788,36 @@ function syncClaudeRoutingSectionsForCli() {
561
788
  ensureGlobalClaudeRoutingSection(CLAUDE_DIR),
562
789
  ];
563
790
  } catch (error) {
564
- const reason = error instanceof Error ? error.message : "routing_sync_failed";
565
- return [{ action: "unchanged", path: join(PKG_ROOT, "CLAUDE.md"), skipped: true, reason }];
791
+ const reason =
792
+ error instanceof Error ? error.message : "routing_sync_failed";
793
+ return [
794
+ {
795
+ action: "unchanged",
796
+ path: join(PKG_ROOT, "CLAUDE.md"),
797
+ skipped: true,
798
+ reason,
799
+ },
800
+ ];
566
801
  }
567
802
  }
568
803
 
569
804
  function getClaudeRoutingSyncSummary(results) {
570
- return results.reduce((summary, result) => ({
571
- changed: summary.changed + (result.action === "created" || result.action === "updated" ? 1 : 0),
572
- skipped: summary.skipped + (result.skipped ? 1 : 0),
573
- }), { changed: 0, skipped: 0 });
805
+ return results.reduce(
806
+ (summary, result) => ({
807
+ changed:
808
+ summary.changed +
809
+ (result.action === "created" || result.action === "updated" ? 1 : 0),
810
+ skipped: summary.skipped + (result.skipped ? 1 : 0),
811
+ }),
812
+ { changed: 0, skipped: 0 },
813
+ );
574
814
  }
575
815
 
576
816
  // ── 크로스 셸 진단 ──
577
817
 
578
818
  function checkCliCrossShell(cmd, installHint) {
579
- const shells = process.platform === "win32" ? ["bash", "cmd", "pwsh"] : ["bash"];
819
+ const shells =
820
+ process.platform === "win32" ? ["bash", "cmd", "pwsh"] : ["bash"];
580
821
  let anyFound = false;
581
822
  let bashMissing = false;
582
823
  const shellResults = [];
@@ -595,7 +836,12 @@ function checkCliCrossShell(cmd, installHint) {
595
836
  } else {
596
837
  fail(`${shell}: 미발견`);
597
838
  if (shell === "bash") bashMissing = true;
598
- shellResults.push({ shell, status: "missing", path: null, fix: installHint });
839
+ shellResults.push({
840
+ shell,
841
+ status: "missing",
842
+ path: null,
843
+ fix: installHint,
844
+ });
599
845
  }
600
846
  }
601
847
 
@@ -620,7 +866,7 @@ function checkCliCrossShell(cmd, installHint) {
620
866
  bashMissing,
621
867
  shells: shellResults,
622
868
  status: "degraded",
623
- fix: 'bash PATH를 정리한 뒤 `tfx doctor`를 다시 실행하세요.',
869
+ fix: "bash PATH를 정리한 뒤 `tfx doctor`를 다시 실행하세요.",
624
870
  };
625
871
  }
626
872
  return {
@@ -677,7 +923,11 @@ function previewStatusLineAction() {
677
923
  return {
678
924
  type: "statusLine",
679
925
  path: settingsPath,
680
- change: currentCmd.includes("hud-qos-status.mjs") ? "noop" : (currentCmd ? "update" : "create"),
926
+ change: currentCmd.includes("hud-qos-status.mjs")
927
+ ? "noop"
928
+ : currentCmd
929
+ ? "update"
930
+ : "create",
681
931
  current: currentCmd || null,
682
932
  target: hudPath,
683
933
  };
@@ -744,7 +994,9 @@ function previewClaudeRoutingAction() {
744
994
  }
745
995
 
746
996
  const globalContent = readFileSync(globalClaudePath, "utf8");
747
- const hasRouting = globalContent.includes("<routing>") || globalContent.includes("## triflux CLI 라우팅");
997
+ const hasRouting =
998
+ globalContent.includes("<routing>") ||
999
+ globalContent.includes("## triflux CLI 라우팅");
748
1000
 
749
1001
  return {
750
1002
  type: "claude-guidance",
@@ -756,7 +1008,9 @@ function previewClaudeRoutingAction() {
756
1008
 
757
1009
  function buildSetupDryRunPlan() {
758
1010
  const actions = [
759
- ...SYNC_MAP.map(({ src, dst, label }) => describeSyncAction(src, dst, label)),
1011
+ ...SYNC_MAP.map(({ src, dst, label }) =>
1012
+ describeSyncAction(src, dst, label),
1013
+ ),
760
1014
  ...listSkillSyncActions(),
761
1015
  ];
762
1016
  actions.push(previewClaudeRoutingAction());
@@ -793,8 +1047,13 @@ function cmdSetup(options = {}) {
793
1047
  }
794
1048
  {
795
1049
  const claudeGuide = ensureGlobalClaudeRoutingSection(CLAUDE_DIR);
796
- if (claudeGuide.skipped) warn(`CLAUDE.md 라우팅 섹션 확인 실패: ${claudeGuide.reason}`);
797
- else if (claudeGuide.action === "created" || claudeGuide.action === "updated") ok("CLAUDE.md: 전역 triflux 라우팅 요약 갱신");
1050
+ if (claudeGuide.skipped)
1051
+ warn(`CLAUDE.md 라우팅 섹션 확인 실패: ${claudeGuide.reason}`);
1052
+ else if (
1053
+ claudeGuide.action === "created" ||
1054
+ claudeGuide.action === "updated"
1055
+ )
1056
+ ok("CLAUDE.md: 전역 triflux 라우팅 요약 갱신");
798
1057
  else ok("CLAUDE.md: 전역 triflux 라우팅 요약 유지");
799
1058
  }
800
1059
 
@@ -833,7 +1092,10 @@ function cmdSetup(options = {}) {
833
1092
  const rSrc = join(refSrc, refFile);
834
1093
  const rDst = join(refDst, refFile);
835
1094
  if (statSync(rSrc).isFile()) {
836
- if (!existsSync(rDst) || readFileSync(rSrc, "utf8") !== readFileSync(rDst, "utf8")) {
1095
+ if (
1096
+ !existsSync(rDst) ||
1097
+ readFileSync(rSrc, "utf8") !== readFileSync(rDst, "utf8")
1098
+ ) {
837
1099
  copyFileSync(rSrc, rDst);
838
1100
  }
839
1101
  }
@@ -845,7 +1107,10 @@ function cmdSetup(options = {}) {
845
1107
  const src = join(srcDir, "SKILL.md");
846
1108
  if (!existsSync(src)) continue;
847
1109
  skillTotal++;
848
- skillCount += syncAliasedSkillDir(srcDir, join(skillsDst, alias), { alias, source });
1110
+ skillCount += syncAliasedSkillDir(srcDir, join(skillsDst, alias), {
1111
+ alias,
1112
+ source,
1113
+ });
849
1114
  }
850
1115
  if (skillCount > 0) {
851
1116
  ok(`스킬: ${skillCount}/${skillTotal}개 업데이트됨`);
@@ -855,22 +1120,36 @@ function cmdSetup(options = {}) {
855
1120
  // Stale 스킬 정리 (패키지에서 제거된 tfx-* 스킬 삭제)
856
1121
  const staleCleanup = cleanupStaleSkills(skillsDst, skillsSrc);
857
1122
  if (staleCleanup.count > 0) {
858
- ok(`구형 스킬 ${staleCleanup.count}개 제거: ${staleCleanup.removed.join(", ")}`);
1123
+ ok(
1124
+ `구형 스킬 ${staleCleanup.count}개 제거: ${staleCleanup.removed.join(", ")}`,
1125
+ );
859
1126
  }
860
1127
  }
861
1128
 
862
1129
  // ── psmux 기본 셸 자동 수정 (cmd.exe → PowerShell) ──
863
1130
  if (process.platform === "win32" && which("psmux")) {
864
1131
  try {
865
- const shellOut = execSync("psmux show-options -g default-shell 2>NUL", { encoding: "utf8", timeout: 3000 }).trim();
1132
+ const shellOut = execSync("psmux show-options -g default-shell 2>NUL", {
1133
+ encoding: "utf8",
1134
+ timeout: 3000,
1135
+ }).trim();
866
1136
  if (!/powershell|pwsh/i.test(shellOut)) {
867
- const pwsh = which("pwsh") ? "pwsh" : (which("powershell.exe") ? "powershell.exe" : "");
1137
+ const pwsh = which("pwsh")
1138
+ ? "pwsh"
1139
+ : which("powershell.exe")
1140
+ ? "powershell.exe"
1141
+ : "";
868
1142
  if (pwsh) {
869
- execSync(`psmux set-option -g default-shell "${pwsh}"`, { timeout: 3000, stdio: "pipe" });
1143
+ execSync(`psmux set-option -g default-shell "${pwsh}"`, {
1144
+ timeout: 3000,
1145
+ stdio: "pipe",
1146
+ });
870
1147
  ok(`psmux 기본 셸 → ${pwsh}`);
871
1148
  }
872
1149
  }
873
- } catch { /* psmux 서버 미실행 — 무시 */ }
1150
+ } catch {
1151
+ /* psmux 서버 미실행 — 무시 */
1152
+ }
874
1153
  }
875
1154
 
876
1155
  // ── 결과 추적 ──
@@ -878,16 +1157,29 @@ function cmdSetup(options = {}) {
878
1157
 
879
1158
  if (!skipClaudeMdSync) {
880
1159
  const claudeRoutingResults = syncClaudeRoutingSectionsForCli();
881
- const claudeRoutingSummary = getClaudeRoutingSyncSummary(claudeRoutingResults);
1160
+ const claudeRoutingSummary =
1161
+ getClaudeRoutingSyncSummary(claudeRoutingResults);
882
1162
  if (claudeRoutingSummary.changed > 0) {
883
1163
  ok(`CLAUDE.md 라우팅: ${claudeRoutingSummary.changed}개 파일 반영`);
884
- summary.push({ item: "CLAUDE.md 라우팅", status: "✅", detail: `${claudeRoutingSummary.changed}개 파일 반영` });
1164
+ summary.push({
1165
+ item: "CLAUDE.md 라우팅",
1166
+ status: "✅",
1167
+ detail: `${claudeRoutingSummary.changed}개 파일 반영`,
1168
+ });
885
1169
  } else if (claudeRoutingSummary.skipped > 0) {
886
1170
  ok("CLAUDE.md 라우팅: 대상 파일 없음 (건너뜀)");
887
- summary.push({ item: "CLAUDE.md 라우팅", status: "⏭️", detail: "대상 파일 없음" });
1171
+ summary.push({
1172
+ item: "CLAUDE.md 라우팅",
1173
+ status: "⏭️",
1174
+ detail: "대상 파일 없음",
1175
+ });
888
1176
  } else {
889
1177
  ok("CLAUDE.md 라우팅: 최신 상태");
890
- summary.push({ item: "CLAUDE.md 라우팅", status: "✅", detail: "최신 상태" });
1178
+ summary.push({
1179
+ item: "CLAUDE.md 라우팅",
1180
+ status: "✅",
1181
+ detail: "최신 상태",
1182
+ });
891
1183
  }
892
1184
  }
893
1185
 
@@ -897,11 +1189,21 @@ function cmdSetup(options = {}) {
897
1189
  warn(`Codex profiles 설정 실패: ${reason}`);
898
1190
  summary.push({ item: "Codex profiles", status: "⚠️", detail: reason });
899
1191
  } else if (codexProfileResult.changed > 0) {
900
- ok(`Codex profiles: ${codexProfileResult.changed}개 반영됨 (~/.codex/config.toml)`);
901
- summary.push({ item: "Codex profiles", status: "✅", detail: `${codexProfileResult.changed}개 반영됨` });
1192
+ ok(
1193
+ `Codex profiles: ${codexProfileResult.changed}개 반영됨 (~/.codex/config.toml)`,
1194
+ );
1195
+ summary.push({
1196
+ item: "Codex profiles",
1197
+ status: "✅",
1198
+ detail: `${codexProfileResult.changed}개 반영됨`,
1199
+ });
902
1200
  } else {
903
1201
  ok("Codex profiles: 이미 준비됨");
904
- summary.push({ item: "Codex profiles", status: "✅", detail: "이미 준비됨" });
1202
+ summary.push({
1203
+ item: "Codex profiles",
1204
+ status: "✅",
1205
+ detail: "이미 준비됨",
1206
+ });
905
1207
  }
906
1208
 
907
1209
  // Gemini 프로필
@@ -911,14 +1213,28 @@ function cmdSetup(options = {}) {
911
1213
  warn(`Gemini profiles 설정 실패: ${reason}`);
912
1214
  summary.push({ item: "Gemini profiles", status: "⚠️", detail: reason });
913
1215
  } else if (geminiResult.created) {
914
- ok(`Gemini profiles: ${geminiResult.count}개 생성됨 (~/.gemini/triflux-profiles.json)`);
915
- summary.push({ item: "Gemini profiles", status: "✅", detail: `${geminiResult.count}개 생성됨` });
1216
+ ok(
1217
+ `Gemini profiles: ${geminiResult.count}개 생성됨 (~/.gemini/triflux-profiles.json)`,
1218
+ );
1219
+ summary.push({
1220
+ item: "Gemini profiles",
1221
+ status: "✅",
1222
+ detail: `${geminiResult.count}개 생성됨`,
1223
+ });
916
1224
  } else if (geminiResult.added > 0) {
917
1225
  ok(`Gemini profiles: ${geminiResult.added}개 추가됨`);
918
- summary.push({ item: "Gemini profiles", status: "✅", detail: `${geminiResult.added}개 추가됨 (총 ${geminiResult.count}개)` });
1226
+ summary.push({
1227
+ item: "Gemini profiles",
1228
+ status: "✅",
1229
+ detail: `${geminiResult.added}개 추가됨 (총 ${geminiResult.count}개)`,
1230
+ });
919
1231
  } else {
920
1232
  ok(`Gemini profiles: ${geminiResult.count}개 준비됨`);
921
- summary.push({ item: "Gemini profiles", status: "✅", detail: `${geminiResult.count}개 준비됨` });
1233
+ summary.push({
1234
+ item: "Gemini profiles",
1235
+ status: "✅",
1236
+ detail: `${geminiResult.count}개 준비됨`,
1237
+ });
922
1238
  }
923
1239
 
924
1240
  // hub MCP 사전 등록 (서버 미실행이어도 설정만 등록 — hub start 시 즉시 사용 가능)
@@ -944,12 +1260,18 @@ function cmdSetup(options = {}) {
944
1260
  const currentCmd = settings.statusLine?.command || "";
945
1261
  if (currentCmd.includes("hud-qos-status.mjs")) {
946
1262
  ok("statusLine 이미 설정됨");
947
- summary.push({ item: "HUD statusLine", status: "✅", detail: "이미 설정됨" });
1263
+ summary.push({
1264
+ item: "HUD statusLine",
1265
+ status: "✅",
1266
+ detail: "이미 설정됨",
1267
+ });
948
1268
  } else {
949
1269
  const nodePath = process.execPath.replace(/\\/g, "/");
950
1270
  const hudForward = hudPath.replace(/\\/g, "/");
951
1271
  const nodeRef = nodePath.includes(" ") ? `"${nodePath}"` : nodePath;
952
- const hudRef = hudForward.includes(" ") ? `"${hudForward}"` : hudForward;
1272
+ const hudRef = hudForward.includes(" ")
1273
+ ? `"${hudForward}"`
1274
+ : hudForward;
953
1275
 
954
1276
  if (currentCmd) {
955
1277
  warn(`기존 statusLine 덮어쓰기: ${currentCmd}`);
@@ -960,9 +1282,17 @@ function cmdSetup(options = {}) {
960
1282
  command: `${nodeRef} ${hudRef}`,
961
1283
  };
962
1284
 
963
- writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + "\n", "utf8");
1285
+ writeFileSync(
1286
+ settingsPath,
1287
+ JSON.stringify(settings, null, 2) + "\n",
1288
+ "utf8",
1289
+ );
964
1290
  ok("statusLine 설정 완료 — 세션 재시작 후 HUD 표시");
965
- summary.push({ item: "HUD statusLine", status: "✅", detail: "설정 완료" });
1291
+ summary.push({
1292
+ item: "HUD statusLine",
1293
+ status: "✅",
1294
+ detail: "설정 완료",
1295
+ });
966
1296
  }
967
1297
  } catch (e) {
968
1298
  throw createCliError(`settings.json 처리 실패: ${e.message}`, {
@@ -974,7 +1304,11 @@ function cmdSetup(options = {}) {
974
1304
  }
975
1305
  } else {
976
1306
  warn("HUD 파일 없음 — 먼저 파일 동기화 필요");
977
- summary.push({ item: "HUD statusLine", status: "⚠️", detail: "HUD 파일 없음" });
1307
+ summary.push({
1308
+ item: "HUD statusLine",
1309
+ status: "⚠️",
1310
+ detail: "HUD 파일 없음",
1311
+ });
978
1312
  }
979
1313
 
980
1314
  // CLI 존재 확인
@@ -986,27 +1320,41 @@ function cmdSetup(options = {}) {
986
1320
  if (which(name)) {
987
1321
  summary.push({ item: `${name} CLI`, status: "✅", detail: "설치됨" });
988
1322
  } else {
989
- summary.push({ item: `${name} CLI`, status: "⏭️", detail: `미설치 (${install})` });
1323
+ summary.push({
1324
+ item: `${name} CLI`,
1325
+ status: "⏭️",
1326
+ detail: `미설치 (${install})`,
1327
+ });
990
1328
  }
991
1329
  }
992
1330
 
993
1331
  // Star request (버전 게이팅 + 인터랙티브 [y/n])
994
- const showStar = STAR_PROMPT_VERSIONS.length === 0 || STAR_PROMPT_VERSIONS.includes(PKG.version);
1332
+ const showStar =
1333
+ STAR_PROMPT_VERSIONS.length === 0 ||
1334
+ STAR_PROMPT_VERSIONS.includes(PKG.version);
995
1335
  if (showStar) {
996
1336
  let ghOk = false;
997
1337
  try {
998
- execFileSync("gh", ["auth", "status"], { timeout: 5000, stdio: ["pipe", "pipe", "pipe"] });
1338
+ execFileSync("gh", ["auth", "status"], {
1339
+ timeout: 5000,
1340
+ stdio: ["pipe", "pipe", "pipe"],
1341
+ });
999
1342
  ghOk = true;
1000
1343
  } catch {}
1001
1344
 
1002
1345
  if (!ghOk) {
1003
1346
  // gh 미설치/미인증 — URL만 표시
1004
1347
  console.log();
1005
- info(`${AMBER}⭐${RESET} 하나가 큰 차이를 만듭니다. ${CYAN}https://github.com/tellang/triflux${RESET}`);
1348
+ info(
1349
+ `${AMBER}⭐${RESET} 하나가 큰 차이를 만듭니다. ${CYAN}https://github.com/tellang/triflux${RESET}`,
1350
+ );
1006
1351
  } else {
1007
1352
  let alreadyStarred = false;
1008
1353
  try {
1009
- execFileSync("gh", ["api", "user/starred/tellang/triflux"], { timeout: 5000, stdio: ["pipe", "pipe", "pipe"] });
1354
+ execFileSync("gh", ["api", "user/starred/tellang/triflux"], {
1355
+ timeout: 5000,
1356
+ stdio: ["pipe", "pipe", "pipe"],
1357
+ });
1010
1358
  alreadyStarred = true;
1011
1359
  } catch {}
1012
1360
 
@@ -1016,7 +1364,9 @@ function cmdSetup(options = {}) {
1016
1364
  } else {
1017
1365
  // 인터랙티브 confirm
1018
1366
  console.log();
1019
- process.stdout.write(` ${AMBER}⭐${RESET} 하나가 큰 차이를 만듭니다. Star? ${DIM}[y/N]${RESET} `);
1367
+ process.stdout.write(
1368
+ ` ${AMBER}⭐${RESET} 하나가 큰 차이를 만듭니다. Star? ${DIM}[y/N]${RESET} `,
1369
+ );
1020
1370
  let answer = "";
1021
1371
  try {
1022
1372
  const buf = Buffer.alloc(128);
@@ -1027,9 +1377,14 @@ function cmdSetup(options = {}) {
1027
1377
  }
1028
1378
  if (answer.startsWith("y")) {
1029
1379
  try {
1030
- execFileSync("gh", ["api", "-X", "PUT", "/user/starred/tellang/triflux"], {
1031
- timeout: 5000, stdio: ["pipe", "pipe", "pipe"],
1032
- });
1380
+ execFileSync(
1381
+ "gh",
1382
+ ["api", "-X", "PUT", "/user/starred/tellang/triflux"],
1383
+ {
1384
+ timeout: 5000,
1385
+ stdio: ["pipe", "pipe", "pipe"],
1386
+ },
1387
+ );
1033
1388
  ok(`함께해 주셔서 감사합니다. ${AMBER}⭐${RESET}`);
1034
1389
  } catch {
1035
1390
  info(`${CYAN}https://github.com/tellang/triflux${RESET}`);
@@ -1072,30 +1427,42 @@ function computeHookCoverage(settings, managedHooks) {
1072
1427
  duplicates: [],
1073
1428
  };
1074
1429
 
1075
- const hooksByEvent = settings?.hooks && typeof settings.hooks === "object" ? settings.hooks : {};
1430
+ const hooksByEvent =
1431
+ settings?.hooks && typeof settings.hooks === "object" ? settings.hooks : {};
1076
1432
 
1077
1433
  // 이벤트별 orchestrator 존재 여부를 캐시
1078
1434
  const orchestratorByEvent = {};
1079
1435
  for (const [event, entries] of Object.entries(hooksByEvent)) {
1080
- orchestratorByEvent[event] = Array.isArray(entries) && entries.some((entry) =>
1081
- Array.isArray(entry?.hooks) &&
1082
- entry.hooks.some((hook) =>
1083
- typeof hook?.command === "string" && hook.command.includes("hook-orchestrator"),
1084
- ),
1085
- );
1436
+ orchestratorByEvent[event] =
1437
+ Array.isArray(entries) &&
1438
+ entries.some(
1439
+ (entry) =>
1440
+ Array.isArray(entry?.hooks) &&
1441
+ entry.hooks.some(
1442
+ (hook) =>
1443
+ typeof hook?.command === "string" &&
1444
+ hook.command.includes("hook-orchestrator"),
1445
+ ),
1446
+ );
1086
1447
  }
1087
1448
 
1088
1449
  for (const spec of managedHooks) {
1089
- const eventEntries = Array.isArray(hooksByEvent[spec.event]) ? hooksByEvent[spec.event] : [];
1450
+ const eventEntries = Array.isArray(hooksByEvent[spec.event])
1451
+ ? hooksByEvent[spec.event]
1452
+ : [];
1090
1453
 
1091
1454
  // orchestrator가 있으면 registry 훅을 체이닝하므로 "registered"로 간주
1092
1455
  if (orchestratorByEvent[spec.event]) {
1093
1456
  coverage.registered++;
1094
1457
 
1095
1458
  // 동시에 개별 훅도 직접 등록되어 있으면 → 이중 실행 (duplicate)
1096
- const directlyRegistered = eventEntries.some((entry) =>
1097
- Array.isArray(entry?.hooks) &&
1098
- entry.hooks.some((hook) => extractManagedHookFilename(hook?.command) === spec.fileName),
1459
+ const directlyRegistered = eventEntries.some(
1460
+ (entry) =>
1461
+ Array.isArray(entry?.hooks) &&
1462
+ entry.hooks.some(
1463
+ (hook) =>
1464
+ extractManagedHookFilename(hook?.command) === spec.fileName,
1465
+ ),
1099
1466
  );
1100
1467
  if (directlyRegistered) {
1101
1468
  coverage.duplicates.push(toHookCoverageName(spec.fileName, spec.id));
@@ -1104,9 +1471,12 @@ function computeHookCoverage(settings, managedHooks) {
1104
1471
  }
1105
1472
 
1106
1473
  // orchestrator 없으면 기존 방식: 개별 훅 직접 등록 확인
1107
- const found = eventEntries.some((entry) =>
1108
- Array.isArray(entry?.hooks) &&
1109
- entry.hooks.some((hook) => extractManagedHookFilename(hook?.command) === spec.fileName),
1474
+ const found = eventEntries.some(
1475
+ (entry) =>
1476
+ Array.isArray(entry?.hooks) &&
1477
+ entry.hooks.some(
1478
+ (hook) => extractManagedHookFilename(hook?.command) === spec.fileName,
1479
+ ),
1110
1480
  );
1111
1481
  if (found) {
1112
1482
  coverage.registered++;
@@ -1121,13 +1491,17 @@ function computeHookCoverage(settings, managedHooks) {
1121
1491
  function formatPathForDisplay(filePath) {
1122
1492
  const value = String(filePath || "").replace(/\\/g, "/");
1123
1493
  const homePath = homedir().replace(/\\/g, "/");
1124
- return value.startsWith(homePath) ? `~${value.slice(homePath.length)}` : value;
1494
+ return value.startsWith(homePath)
1495
+ ? `~${value.slice(homePath.length)}`
1496
+ : value;
1125
1497
  }
1126
1498
 
1127
1499
  function renderTable(headers, rows) {
1128
1500
  if (!rows.length) return;
1129
1501
  const widths = headers.map((header, index) => {
1130
- const cellWidths = rows.map((row) => stripAnsi(String(row[index] ?? "")).length);
1502
+ const cellWidths = rows.map(
1503
+ (row) => stripAnsi(String(row[index] ?? "")).length,
1504
+ );
1131
1505
  return Math.max(stripAnsi(header).length, ...cellWidths);
1132
1506
  });
1133
1507
 
@@ -1135,7 +1509,8 @@ function renderTable(headers, rows) {
1135
1509
  const text = String(cell ?? "");
1136
1510
  return text + " ".repeat(Math.max(0, width - stripAnsi(text).length));
1137
1511
  };
1138
- const formatRow = (row) => row.map((cell, index) => padCell(cell, widths[index])).join(" ");
1512
+ const formatRow = (row) =>
1513
+ row.map((cell, index) => padCell(cell, widths[index])).join(" ");
1139
1514
  console.log(` ${formatRow(headers)}`);
1140
1515
  console.log(` ${widths.map((width) => "─".repeat(width)).join(" ")}`);
1141
1516
  for (const row of rows) {
@@ -1175,12 +1550,15 @@ function inspectSerenaMcpConfig(configContent) {
1175
1550
  };
1176
1551
  }
1177
1552
 
1178
- const hasProjectBinding = section.includes("--project-from-cwd")
1179
- || /--project(?:\s|=|")/.test(section);
1180
- const hasContextCodex = /--context(?:\s|",\s*")?codex/i.test(section) || /"codex"/i.test(section);
1553
+ const hasProjectBinding =
1554
+ section.includes("--project-from-cwd") ||
1555
+ /--project(?:\s|=|")/.test(section);
1556
+ const hasContextCodex =
1557
+ /--context(?:\s|",\s*")?codex/i.test(section) || /"codex"/i.test(section);
1181
1558
  const timeoutMatch = section.match(/startup_timeout_sec\s*=\s*([0-9.]+)/i);
1182
1559
  const startupTimeoutSec = timeoutMatch ? Number(timeoutMatch[1]) : null;
1183
- const timeoutRecommended = startupTimeoutSec !== null && startupTimeoutSec >= 30;
1560
+ const timeoutRecommended =
1561
+ startupTimeoutSec !== null && startupTimeoutSec >= 30;
1184
1562
 
1185
1563
  return {
1186
1564
  present: true,
@@ -1220,10 +1598,17 @@ function buildMcpStatusRows(statusInfo) {
1220
1598
  if (row.status === "present") detail = row.actualUrl || row.expectedUrl;
1221
1599
  else if (row.status === "missing") detail = "registry only";
1222
1600
  else if (row.status === "missing-file") detail = "config missing";
1223
- else if (row.status === "mismatch") detail = `expected ${row.expectedUrl}`;
1601
+ else if (row.status === "mismatch")
1602
+ detail = `expected ${row.expectedUrl}`;
1224
1603
  else if (row.status === "invalid-config") detail = "parse error";
1225
1604
  else if (row.status === "stdio") detail = "configured as stdio";
1226
- return [row.name, row.label, statusBadge(row.status), formatPathForDisplay(row.filePath), detail];
1605
+ return [
1606
+ row.name,
1607
+ row.label,
1608
+ statusBadge(row.status),
1609
+ formatPathForDisplay(row.filePath),
1610
+ detail,
1611
+ ];
1227
1612
  });
1228
1613
 
1229
1614
  const stdioRows = statusInfo.rows
@@ -1246,11 +1631,14 @@ function ensureValidRegistryState() {
1246
1631
  registryState = inspectRegistry();
1247
1632
  }
1248
1633
  if (!registryState.valid) {
1249
- throw createCliError(`MCP registry invalid: ${registryState.errors.join("; ")}`, {
1250
- exitCode: EXIT_CONFIG_ERROR,
1251
- reason: "configError",
1252
- fix: `${registryState.path}의 JSON 구조를 수정하세요.`,
1253
- });
1634
+ throw createCliError(
1635
+ `MCP registry invalid: ${registryState.errors.join("; ")}`,
1636
+ {
1637
+ exitCode: EXIT_CONFIG_ERROR,
1638
+ reason: "configError",
1639
+ fix: `${registryState.path}의 JSON 구조를 수정하세요.`,
1640
+ },
1641
+ );
1254
1642
  }
1255
1643
  return registryState;
1256
1644
  }
@@ -1267,8 +1655,14 @@ async function cmdDoctor(options = {}) {
1267
1655
  };
1268
1656
 
1269
1657
  return await withConsoleSilenced(json, async () => {
1270
- const modeLabel = reset ? ` ${RED}--reset${RESET}` : fix ? ` ${YELLOW}--fix${RESET}` : "";
1271
- console.log(`\n ${AMBER}${BOLD}⬡ triflux doctor${RESET} ${VER}${modeLabel}\n`);
1658
+ const modeLabel = reset
1659
+ ? ` ${RED}--reset${RESET}`
1660
+ : fix
1661
+ ? ` ${YELLOW}--fix${RESET}`
1662
+ : "";
1663
+ console.log(
1664
+ `\n ${AMBER}${BOLD}⬡ triflux doctor${RESET} ${VER}${modeLabel}\n`,
1665
+ );
1272
1666
  console.log(` ${LINE}`);
1273
1667
 
1274
1668
  // ── reset 모드: 캐시 전체 초기화 ──
@@ -1298,7 +1692,12 @@ async function cmdDoctor(options = {}) {
1298
1692
  report.actions.push({ type: "delete", path: fp, status: "ok" });
1299
1693
  ok(`삭제됨: ${name}`);
1300
1694
  } catch (e) {
1301
- report.actions.push({ type: "delete", path: fp, status: "failed", message: e.message });
1695
+ report.actions.push({
1696
+ type: "delete",
1697
+ path: fp,
1698
+ status: "failed",
1699
+ message: e.message,
1700
+ });
1302
1701
  fail(`삭제 실패: ${name} — ${e.message}`);
1303
1702
  }
1304
1703
  }
@@ -1314,38 +1713,86 @@ async function cmdDoctor(options = {}) {
1314
1713
  const mcpCheck = join(PKG_ROOT, "scripts", "mcp-check.mjs");
1315
1714
  if (existsSync(mcpCheck)) {
1316
1715
  try {
1317
- execFileSync(process.execPath, [mcpCheck], { timeout: 15000, stdio: "ignore", windowsHide: true });
1318
- report.actions.push({ type: "rebuild", name: "mcp-inventory", status: "ok" });
1716
+ execFileSync(process.execPath, [mcpCheck], {
1717
+ timeout: 15000,
1718
+ stdio: "ignore",
1719
+ windowsHide: true,
1720
+ });
1721
+ report.actions.push({
1722
+ type: "rebuild",
1723
+ name: "mcp-inventory",
1724
+ status: "ok",
1725
+ });
1319
1726
  ok("MCP 인벤토리 재생성됨");
1320
1727
  } catch {
1321
- report.actions.push({ type: "rebuild", name: "mcp-inventory", status: "failed" });
1728
+ report.actions.push({
1729
+ type: "rebuild",
1730
+ name: "mcp-inventory",
1731
+ status: "failed",
1732
+ });
1322
1733
  warn("MCP 인벤토리 재생성 실패 — 다음 세션에서 자동 재시도");
1323
1734
  }
1324
1735
  }
1325
1736
  const hudScript = join(CLAUDE_DIR, "hud", "hud-qos-status.mjs");
1326
1737
  if (existsSync(hudScript)) {
1327
1738
  try {
1328
- execFileSync(process.execPath, [hudScript, "--refresh-claude-usage"], { timeout: 20000, stdio: "ignore", windowsHide: true });
1329
- report.actions.push({ type: "rebuild", name: "claude-usage-cache", status: "ok" });
1739
+ execFileSync(
1740
+ process.execPath,
1741
+ [hudScript, "--refresh-claude-usage"],
1742
+ { timeout: 20000, stdio: "ignore", windowsHide: true },
1743
+ );
1744
+ report.actions.push({
1745
+ type: "rebuild",
1746
+ name: "claude-usage-cache",
1747
+ status: "ok",
1748
+ });
1330
1749
  ok("Claude 사용량 캐시 재생성됨");
1331
1750
  } catch {
1332
- report.actions.push({ type: "rebuild", name: "claude-usage-cache", status: "failed" });
1751
+ report.actions.push({
1752
+ type: "rebuild",
1753
+ name: "claude-usage-cache",
1754
+ status: "failed",
1755
+ });
1333
1756
  warn("Claude 사용량 캐시 재생성 실패 — 다음 API 호출 시 자동 생성");
1334
1757
  }
1335
1758
  try {
1336
- execFileSync(process.execPath, [hudScript, "--refresh-codex-rate-limits"], { timeout: 15000, stdio: "ignore", windowsHide: true });
1337
- report.actions.push({ type: "rebuild", name: "codex-rate-limits-cache", status: "ok" });
1759
+ execFileSync(
1760
+ process.execPath,
1761
+ [hudScript, "--refresh-codex-rate-limits"],
1762
+ { timeout: 15000, stdio: "ignore", windowsHide: true },
1763
+ );
1764
+ report.actions.push({
1765
+ type: "rebuild",
1766
+ name: "codex-rate-limits-cache",
1767
+ status: "ok",
1768
+ });
1338
1769
  ok("Codex 레이트 리밋 캐시 재생성됨");
1339
1770
  } catch {
1340
- report.actions.push({ type: "rebuild", name: "codex-rate-limits-cache", status: "failed" });
1771
+ report.actions.push({
1772
+ type: "rebuild",
1773
+ name: "codex-rate-limits-cache",
1774
+ status: "failed",
1775
+ });
1341
1776
  warn("Codex 레이트 리밋 캐시 재생성 실패");
1342
1777
  }
1343
1778
  try {
1344
- execFileSync(process.execPath, [hudScript, "--refresh-gemini-quota"], { timeout: 15000, stdio: "ignore", windowsHide: true });
1345
- report.actions.push({ type: "rebuild", name: "gemini-quota-cache", status: "ok" });
1779
+ execFileSync(
1780
+ process.execPath,
1781
+ [hudScript, "--refresh-gemini-quota"],
1782
+ { timeout: 15000, stdio: "ignore", windowsHide: true },
1783
+ );
1784
+ report.actions.push({
1785
+ type: "rebuild",
1786
+ name: "gemini-quota-cache",
1787
+ status: "ok",
1788
+ });
1346
1789
  ok("Gemini 쿼터 캐시 재생성됨");
1347
1790
  } catch {
1348
- report.actions.push({ type: "rebuild", name: "gemini-quota-cache", status: "failed" });
1791
+ report.actions.push({
1792
+ type: "rebuild",
1793
+ name: "gemini-quota-cache",
1794
+ status: "failed",
1795
+ });
1349
1796
  warn("Gemini 쿼터 캐시 재생성 실패");
1350
1797
  }
1351
1798
  }
@@ -1353,114 +1800,178 @@ async function cmdDoctor(options = {}) {
1353
1800
  const { buildAll } = await import("../scripts/cache-warmup.mjs");
1354
1801
  const warmupSummary = buildAll({ cwd: process.cwd(), force: true });
1355
1802
  if (warmupSummary.ok) {
1356
- report.actions.push({ type: "rebuild", name: "warmup-caches", status: "ok", built: warmupSummary.built });
1803
+ report.actions.push({
1804
+ type: "rebuild",
1805
+ name: "warmup-caches",
1806
+ status: "ok",
1807
+ built: warmupSummary.built,
1808
+ });
1357
1809
  ok("Phase 1 웜업 캐시 재생성됨");
1358
1810
  } else {
1359
- report.actions.push({ type: "rebuild", name: "warmup-caches", status: "failed" });
1811
+ report.actions.push({
1812
+ type: "rebuild",
1813
+ name: "warmup-caches",
1814
+ status: "failed",
1815
+ });
1360
1816
  warn("Phase 1 웜업 캐시 재생성 실패");
1361
1817
  }
1362
1818
  } catch {
1363
- report.actions.push({ type: "rebuild", name: "warmup-caches", status: "failed" });
1819
+ report.actions.push({
1820
+ type: "rebuild",
1821
+ name: "warmup-caches",
1822
+ status: "failed",
1823
+ });
1364
1824
  warn("Phase 1 웜업 캐시 재생성 실패");
1365
1825
  }
1366
1826
  console.log(`\n ${LINE}`);
1367
- console.log(` ${GREEN_BRIGHT}${BOLD}✓ 캐시 초기화 + 재생성 완료${RESET}\n`);
1368
- report.status = report.actions.some((action) => action.status === "failed") ? "issues" : "ok";
1369
- report.issue_count = report.actions.filter((action) => action.status === "failed").length;
1827
+ console.log(
1828
+ ` ${GREEN_BRIGHT}${BOLD}✓ 캐시 초기화 + 재생성 완료${RESET}\n`,
1829
+ );
1830
+ report.status = report.actions.some(
1831
+ (action) => action.status === "failed",
1832
+ )
1833
+ ? "issues"
1834
+ : "ok";
1835
+ report.issue_count = report.actions.filter(
1836
+ (action) => action.status === "failed",
1837
+ ).length;
1370
1838
  if (json) printJson(report);
1371
1839
  return report;
1372
1840
  }
1373
1841
 
1374
1842
  // ── fix 모드: 파일 동기화 + 캐시 정리 후 진단 ──
1375
1843
  if (fix) {
1376
- section("Auto Fix");
1377
- for (const target of SYNC_MAP) {
1378
- syncFile(target.src, target.dst, target.label);
1379
- }
1380
- {
1381
- const claudeGuide = ensureGlobalClaudeRoutingSection(CLAUDE_DIR);
1382
- if (claudeGuide.skipped) warn(`CLAUDE.md 라우팅 섹션 확인 실패: ${claudeGuide.reason}`);
1383
- else if (claudeGuide.action === "created" || claudeGuide.action === "updated") ok("CLAUDE.md: 전역 triflux 라우팅 요약 갱신");
1384
- }
1385
- // 스킬 동기화
1386
- const fSkillsSrc = join(PKG_ROOT, "skills");
1387
- const fSkillsDst = join(CLAUDE_DIR, "skills");
1388
- if (existsSync(fSkillsSrc)) {
1389
- let sc = 0, st = 0;
1390
- for (const name of readdirSync(fSkillsSrc)) {
1391
- const src = join(fSkillsSrc, name, "SKILL.md");
1392
- const dst = join(fSkillsDst, name, "SKILL.md");
1393
- if (!existsSync(src)) continue;
1394
- st++;
1395
- const dstDir = dirname(dst);
1396
- if (!existsSync(dstDir)) mkdirSync(dstDir, { recursive: true });
1397
- if (!existsSync(dst)) { copyFileSync(src, dst); sc++; }
1398
- else if (readFileSync(src, "utf8") !== readFileSync(dst, "utf8")) { copyFileSync(src, dst); sc++; }
1399
- }
1400
- if (sc > 0) ok(`스킬: ${sc}/${st}개 업데이트됨`);
1401
- else ok(`스킬: ${st}개 최신 상태`);
1402
- }
1403
- const profileFix = ensureCodexProfiles();
1404
- if (!profileFix.ok) {
1405
- warn(`Codex Profiles 자동 복구 실패: ${renderErrorMessage(profileFix.message)}`);
1406
- } else if (profileFix.changed > 0) {
1407
- ok(`Codex Profiles: ${profileFix.changed}개 반영됨`);
1408
- } else {
1409
- info("Codex Profiles: 이미 최신 상태");
1410
- }
1411
- // 에러/스테일 캐시 정리
1412
- const fCacheDir = join(CLAUDE_DIR, "cache");
1413
- const staleNames = ["claude-usage-cache.json", ".claude-refresh-lock", "codex-rate-limits-cache.json"];
1414
- let cleaned = 0;
1415
- for (const name of staleNames) {
1416
- const fp = join(fCacheDir, name);
1417
- if (!existsSync(fp)) continue;
1418
- try {
1419
- const parsed = JSON.parse(readFileSync(fp, "utf8"));
1420
- if (parsed.error || name.startsWith(".")) { unlinkSync(fp); cleaned++; ok(`에러 캐시 정리: ${name}`); }
1421
- } catch { try { unlinkSync(fp); cleaned++; ok(`손상된 캐시 정리: ${name}`); } catch {} }
1422
- }
1423
- if (cleaned === 0) info("에러 캐시 없음");
1424
- try {
1425
- const { fixCaches } = await import("../scripts/cache-doctor.mjs");
1426
- const cacheRepair = await fixCaches({ cwd: process.cwd() });
1427
- if (cacheRepair.fixed.length > 0 && cacheRepair.ok) {
1428
- ok(`웜업 캐시 자동 복구: ${cacheRepair.fixed.join(", ")}`);
1429
- } else if (cacheRepair.fixed.length > 0) {
1430
- warn(`웜업 캐시 자동 복구 실패: ${cacheRepair.fixed.join(", ")}`);
1844
+ section("Auto Fix");
1845
+ for (const target of SYNC_MAP) {
1846
+ syncFile(target.src, target.dst, target.label);
1847
+ }
1848
+ {
1849
+ const claudeGuide = ensureGlobalClaudeRoutingSection(CLAUDE_DIR);
1850
+ if (claudeGuide.skipped)
1851
+ warn(`CLAUDE.md 라우팅 섹션 확인 실패: ${claudeGuide.reason}`);
1852
+ else if (
1853
+ claudeGuide.action === "created" ||
1854
+ claudeGuide.action === "updated"
1855
+ )
1856
+ ok("CLAUDE.md: 전역 triflux 라우팅 요약 갱신");
1857
+ }
1858
+ // 스킬 동기화
1859
+ const fSkillsSrc = join(PKG_ROOT, "skills");
1860
+ const fSkillsDst = join(CLAUDE_DIR, "skills");
1861
+ if (existsSync(fSkillsSrc)) {
1862
+ let sc = 0,
1863
+ st = 0;
1864
+ for (const name of readdirSync(fSkillsSrc)) {
1865
+ const src = join(fSkillsSrc, name, "SKILL.md");
1866
+ const dst = join(fSkillsDst, name, "SKILL.md");
1867
+ if (!existsSync(src)) continue;
1868
+ st++;
1869
+ const dstDir = dirname(dst);
1870
+ if (!existsSync(dstDir)) mkdirSync(dstDir, { recursive: true });
1871
+ if (!existsSync(dst)) {
1872
+ copyFileSync(src, dst);
1873
+ sc++;
1874
+ } else if (readFileSync(src, "utf8") !== readFileSync(dst, "utf8")) {
1875
+ copyFileSync(src, dst);
1876
+ sc++;
1877
+ }
1878
+ }
1879
+ if (sc > 0) ok(`스킬: ${sc}/${st}개 업데이트됨`);
1880
+ else ok(`스킬: ${st}개 최신 상태`);
1881
+ }
1882
+ const profileFix = ensureCodexProfiles();
1883
+ if (!profileFix.ok) {
1884
+ warn(
1885
+ `Codex Profiles 자동 복구 실패: ${renderErrorMessage(profileFix.message)}`,
1886
+ );
1887
+ } else if (profileFix.changed > 0) {
1888
+ ok(`Codex Profiles: ${profileFix.changed}개 반영됨`);
1431
1889
  } else {
1432
- info("웜업 캐시: 이미 정상 상태");
1890
+ info("Codex Profiles: 이미 최신 상태");
1433
1891
  }
1434
- } catch {
1435
- warn("웜업 캐시 자동 복구 실패");
1436
- }
1437
- const registryStateForFix = inspectRegistry();
1438
- if (registryStateForFix.valid) {
1892
+ // 에러/스테일 캐시 정리
1893
+ const fCacheDir = join(CLAUDE_DIR, "cache");
1894
+ const staleNames = [
1895
+ "claude-usage-cache.json",
1896
+ ".claude-refresh-lock",
1897
+ "codex-rate-limits-cache.json",
1898
+ ];
1899
+ let cleaned = 0;
1900
+ for (const name of staleNames) {
1901
+ const fp = join(fCacheDir, name);
1902
+ if (!existsSync(fp)) continue;
1903
+ try {
1904
+ const parsed = JSON.parse(readFileSync(fp, "utf8"));
1905
+ if (parsed.error || name.startsWith(".")) {
1906
+ unlinkSync(fp);
1907
+ cleaned++;
1908
+ ok(`에러 캐시 정리: ${name}`);
1909
+ }
1910
+ } catch {
1911
+ try {
1912
+ unlinkSync(fp);
1913
+ cleaned++;
1914
+ ok(`손상된 캐시 정리: ${name}`);
1915
+ } catch {}
1916
+ }
1917
+ }
1918
+ if (cleaned === 0) info("에러 캐시 없음");
1439
1919
  try {
1440
- const mcpSync = syncRegistryTargets({ registry: registryStateForFix.registry });
1441
- const updatedCount = mcpSync.actions.filter((action) => action.status === "updated").length;
1442
- const invalidCount = mcpSync.actions.filter((action) => action.status === "invalid-config").length;
1443
- report.actions.push({ type: "mcp-sync", status: invalidCount > 0 ? "issues" : "ok", actions: mcpSync.actions });
1444
- if (updatedCount > 0) ok(`MCP registry 동기화: ${updatedCount}개 설정 반영됨`);
1445
- else info("MCP registry: 이미 최신 상태");
1446
- if (invalidCount > 0) warn(`MCP registry 동기화 건너뜀: parse error ${invalidCount}개`);
1447
- } catch (error) {
1448
- report.actions.push({ type: "mcp-sync", status: "failed", message: error.message });
1449
- warn(`MCP registry 자동 동기화 실패: ${error.message}`);
1450
- }
1451
- } else if (registryStateForFix.exists) {
1452
- saveRegistry(createDefaultRegistry());
1453
- report.actions.push({ type: "mcp-registry-reset", status: "ok" });
1454
- ok("MCP registry 손상 → 기본값으로 재생성됨");
1455
- } else {
1456
- saveRegistry(createDefaultRegistry());
1457
- report.actions.push({ type: "mcp-registry-create", status: "ok" });
1458
- ok("MCP registry 없음 → 기본값으로 자동 생성됨");
1920
+ const { fixCaches } = await import("../scripts/cache-doctor.mjs");
1921
+ const cacheRepair = await fixCaches({ cwd: process.cwd() });
1922
+ if (cacheRepair.fixed.length > 0 && cacheRepair.ok) {
1923
+ ok(`웜업 캐시 자동 복구: ${cacheRepair.fixed.join(", ")}`);
1924
+ } else if (cacheRepair.fixed.length > 0) {
1925
+ warn(`웜업 캐시 자동 복구 실패: ${cacheRepair.fixed.join(", ")}`);
1926
+ } else {
1927
+ info("웜업 캐시: 이미 정상 상태");
1928
+ }
1929
+ } catch {
1930
+ warn("웜업 캐시 자동 복구 실패");
1931
+ }
1932
+ const registryStateForFix = inspectRegistry();
1933
+ if (registryStateForFix.valid) {
1934
+ try {
1935
+ const mcpSync = syncRegistryTargets({
1936
+ registry: registryStateForFix.registry,
1937
+ });
1938
+ const updatedCount = mcpSync.actions.filter(
1939
+ (action) => action.status === "updated",
1940
+ ).length;
1941
+ const invalidCount = mcpSync.actions.filter(
1942
+ (action) => action.status === "invalid-config",
1943
+ ).length;
1944
+ report.actions.push({
1945
+ type: "mcp-sync",
1946
+ status: invalidCount > 0 ? "issues" : "ok",
1947
+ actions: mcpSync.actions,
1948
+ });
1949
+ if (updatedCount > 0)
1950
+ ok(`MCP registry 동기화: ${updatedCount}개 설정 반영됨`);
1951
+ else info("MCP registry: 이미 최신 상태");
1952
+ if (invalidCount > 0)
1953
+ warn(`MCP registry 동기화 건너뜀: parse error ${invalidCount}개`);
1954
+ } catch (error) {
1955
+ report.actions.push({
1956
+ type: "mcp-sync",
1957
+ status: "failed",
1958
+ message: error.message,
1959
+ });
1960
+ warn(`MCP registry 자동 동기화 실패: ${error.message}`);
1961
+ }
1962
+ } else if (registryStateForFix.exists) {
1963
+ saveRegistry(createDefaultRegistry());
1964
+ report.actions.push({ type: "mcp-registry-reset", status: "ok" });
1965
+ ok("MCP registry 손상 → 기본값으로 재생성됨");
1966
+ } else {
1967
+ saveRegistry(createDefaultRegistry());
1968
+ report.actions.push({ type: "mcp-registry-create", status: "ok" });
1969
+ ok("MCP registry 없음 → 기본값으로 자동 생성됨");
1970
+ }
1971
+ console.log(`\n ${LINE}`);
1972
+ info("수정 완료 — 아래 진단 결과를 확인하세요");
1973
+ console.log("");
1459
1974
  }
1460
- console.log(`\n ${LINE}`);
1461
- info("수정 완료 — 아래 진단 결과를 확인하세요");
1462
- console.log("");
1463
- }
1464
1975
 
1465
1976
  let issues = 0;
1466
1977
 
@@ -1469,10 +1980,20 @@ async function cmdDoctor(options = {}) {
1469
1980
  const routeSh = join(CLAUDE_DIR, "scripts", "tfx-route.sh");
1470
1981
  if (existsSync(routeSh)) {
1471
1982
  const ver = getVersion(routeSh);
1472
- addDoctorCheck(report, { name: "tfx-route.sh", status: "ok", path: routeSh, version: ver });
1983
+ addDoctorCheck(report, {
1984
+ name: "tfx-route.sh",
1985
+ status: "ok",
1986
+ path: routeSh,
1987
+ version: ver,
1988
+ });
1473
1989
  ok(`설치됨 ${ver ? `${DIM}v${ver}${RESET}` : ""}`);
1474
1990
  } else {
1475
- addDoctorCheck(report, { name: "tfx-route.sh", status: "missing", path: routeSh, fix: "tfx setup" });
1991
+ addDoctorCheck(report, {
1992
+ name: "tfx-route.sh",
1993
+ status: "missing",
1994
+ path: routeSh,
1995
+ fix: "tfx setup",
1996
+ });
1476
1997
  fail("미설치 — tfx setup 실행 필요");
1477
1998
  issues++;
1478
1999
  }
@@ -1481,16 +2002,29 @@ async function cmdDoctor(options = {}) {
1481
2002
  section("HUD");
1482
2003
  const hud = join(CLAUDE_DIR, "hud", "hud-qos-status.mjs");
1483
2004
  if (existsSync(hud)) {
1484
- addDoctorCheck(report, { name: "hud-qos-status.mjs", status: "ok", path: hud });
2005
+ addDoctorCheck(report, {
2006
+ name: "hud-qos-status.mjs",
2007
+ status: "ok",
2008
+ path: hud,
2009
+ });
1485
2010
  ok("설치됨");
1486
2011
  } else {
1487
- addDoctorCheck(report, { name: "hud-qos-status.mjs", status: "missing", path: hud, optional: true, fix: "tfx setup" });
2012
+ addDoctorCheck(report, {
2013
+ name: "hud-qos-status.mjs",
2014
+ status: "missing",
2015
+ path: hud,
2016
+ optional: true,
2017
+ fix: "tfx setup",
2018
+ });
1488
2019
  warn(`미설치 ${GRAY}(선택사항)${RESET}`);
1489
2020
  }
1490
2021
 
1491
2022
  // 3. Codex CLI
1492
2023
  section(`Codex CLI ${WHITE_BRIGHT}●${RESET}`);
1493
- const codexCli = checkCliCrossShell("codex", "npm install -g @openai/codex");
2024
+ const codexCli = checkCliCrossShell(
2025
+ "codex",
2026
+ "npm install -g @openai/codex",
2027
+ );
1494
2028
  issues += codexCli.issues;
1495
2029
  addDoctorCheck(report, {
1496
2030
  name: "codex",
@@ -1507,9 +2041,13 @@ async function cmdDoctor(options = {}) {
1507
2041
  const missingProfiles = [];
1508
2042
  for (const profile of REQUIRED_CODEX_PROFILES) {
1509
2043
  if (hasProfileSection(codexConfig, profile.name)) {
1510
- ok(`${profile.name}: 정상${profile.proOnly ? ` ${DIM}(Pro 전용)${RESET}` : ""}`);
2044
+ ok(
2045
+ `${profile.name}: 정상${profile.proOnly ? ` ${DIM}(Pro 전용)${RESET}` : ""}`,
2046
+ );
1511
2047
  } else if (profile.proOnly) {
1512
- info(`${profile.name}: 미설정 ${DIM}(Pro 전용 — Plus/기본에서는 불필요)${RESET}`);
2048
+ info(
2049
+ `${profile.name}: 미설정 ${DIM}(Pro 전용 — Plus/기본에서는 불필요)${RESET}`,
2050
+ );
1513
2051
  } else {
1514
2052
  missingProfiles.push(profile.name);
1515
2053
  warn(`${profile.name}: 미설정`);
@@ -1524,7 +2062,12 @@ async function cmdDoctor(options = {}) {
1524
2062
  ...(missingProfiles.length > 0 ? { fix: "tfx setup" } : {}),
1525
2063
  });
1526
2064
  } else {
1527
- addDoctorCheck(report, { name: "codex-profiles", status: "missing", path: CODEX_CONFIG_PATH, fix: "tfx setup" });
2065
+ addDoctorCheck(report, {
2066
+ name: "codex-profiles",
2067
+ status: "missing",
2068
+ path: CODEX_CONFIG_PATH,
2069
+ fix: "tfx setup",
2070
+ });
1528
2071
  warn("config.toml 미존재");
1529
2072
  issues++;
1530
2073
  }
@@ -1532,11 +2075,18 @@ async function cmdDoctor(options = {}) {
1532
2075
  // Codex 구형 모델 감지
1533
2076
  if (existsSync(CODEX_CONFIG_PATH)) {
1534
2077
  const codexContent = readFileSync(CODEX_CONFIG_PATH, "utf8");
1535
- const legacyFound = LEGACY_CODEX_MODELS.filter(m => codexContent.includes(`"${m}"`));
2078
+ const legacyFound = LEGACY_CODEX_MODELS.filter((m) =>
2079
+ codexContent.includes(`"${m}"`),
2080
+ );
1536
2081
  if (legacyFound.length > 0) {
1537
2082
  warn(`구형 모델 감지: ${legacyFound.join(", ")}`);
1538
2083
  info("최신 프로필로 마이그레이션: tfx setup 또는 tfx profile");
1539
- addDoctorCheck(report, { name: "codex-legacy-models", status: "issues", models: legacyFound, fix: "tfx setup" });
2084
+ addDoctorCheck(report, {
2085
+ name: "codex-legacy-models",
2086
+ status: "issues",
2087
+ models: legacyFound,
2088
+ fix: "tfx setup",
2089
+ });
1540
2090
  issues++;
1541
2091
  }
1542
2092
  }
@@ -1548,7 +2098,9 @@ async function cmdDoctor(options = {}) {
1548
2098
  const serenaConfig = inspectSerenaMcpConfig(codexConfig);
1549
2099
  if (!serenaConfig.present) {
1550
2100
  warn("serena MCP 설정 없음");
1551
- info("권장: [mcp_servers.serena]에 --project-from-cwd, --context codex, startup_timeout_sec=30+ 설정");
2101
+ info(
2102
+ "권장: [mcp_servers.serena]에 --project-from-cwd, --context codex, startup_timeout_sec=30+ 설정",
2103
+ );
1552
2104
  addDoctorCheck(report, {
1553
2105
  name: "serena-mcp",
1554
2106
  status: "missing",
@@ -1557,7 +2109,8 @@ async function cmdDoctor(options = {}) {
1557
2109
  });
1558
2110
  issues++;
1559
2111
  } else {
1560
- const hasSerenaIssues = !serenaConfig.hasProjectBinding || !serenaConfig.timeoutRecommended;
2112
+ const hasSerenaIssues =
2113
+ !serenaConfig.hasProjectBinding || !serenaConfig.timeoutRecommended;
1561
2114
 
1562
2115
  if (serenaConfig.hasProjectBinding) ok("project binding: 정상");
1563
2116
  else {
@@ -1589,7 +2142,9 @@ async function cmdDoctor(options = {}) {
1589
2142
  context_codex: serenaConfig.hasContextCodex,
1590
2143
  startup_timeout_sec: serenaConfig.startupTimeoutSec,
1591
2144
  ...(hasSerenaIssues
1592
- ? { fix: "Serena MCP에 --project-from-cwd 와 startup_timeout_sec=30+ 를 설정하세요." }
2145
+ ? {
2146
+ fix: "Serena MCP에 --project-from-cwd 와 startup_timeout_sec=30+ 를 설정하세요.",
2147
+ }
1593
2148
  : {}),
1594
2149
  });
1595
2150
  }
@@ -1606,7 +2161,10 @@ async function cmdDoctor(options = {}) {
1606
2161
 
1607
2162
  // 5. Gemini CLI
1608
2163
  section(`Gemini CLI ${BLUE}●${RESET}`);
1609
- const geminiCli = checkCliCrossShell("gemini", "npm install -g @google/gemini-cli");
2164
+ const geminiCli = checkCliCrossShell(
2165
+ "gemini",
2166
+ "npm install -g @google/gemini-cli",
2167
+ );
1610
2168
  issues += geminiCli.issues;
1611
2169
  addDoctorCheck(report, {
1612
2170
  name: "gemini",
@@ -1617,16 +2175,32 @@ async function cmdDoctor(options = {}) {
1617
2175
  // API 키 검사 제거 — bash exec 기반이므로 API 키 불필요
1618
2176
 
1619
2177
  // Gemini 구형 모델 감지
1620
- const geminiProfilesPath = join(homedir(), ".gemini", "triflux-profiles.json");
1621
- const LEGACY_GEMINI_MODELS = ["gemini-2.0-flash", "gemini-1.5-pro", "gemini-1.5-flash", "gemini-2.5-pro-preview"];
2178
+ const geminiProfilesPath = join(
2179
+ homedir(),
2180
+ ".gemini",
2181
+ "triflux-profiles.json",
2182
+ );
2183
+ const LEGACY_GEMINI_MODELS = [
2184
+ "gemini-2.0-flash",
2185
+ "gemini-1.5-pro",
2186
+ "gemini-1.5-flash",
2187
+ "gemini-2.5-pro-preview",
2188
+ ];
1622
2189
  if (existsSync(geminiProfilesPath)) {
1623
2190
  try {
1624
2191
  const geminiContent = readFileSync(geminiProfilesPath, "utf8");
1625
- const geminiLegacy = LEGACY_GEMINI_MODELS.filter(m => geminiContent.includes(m));
2192
+ const geminiLegacy = LEGACY_GEMINI_MODELS.filter((m) =>
2193
+ geminiContent.includes(m),
2194
+ );
1626
2195
  if (geminiLegacy.length > 0) {
1627
2196
  warn(`구형 모델 감지: ${geminiLegacy.join(", ")}`);
1628
2197
  info("최신 프로필로 마이그레이션: tfx setup 또는 tfx profile");
1629
- addDoctorCheck(report, { name: "gemini-legacy-models", status: "issues", models: geminiLegacy, fix: "tfx setup" });
2198
+ addDoctorCheck(report, {
2199
+ name: "gemini-legacy-models",
2200
+ status: "issues",
2201
+ models: geminiLegacy,
2202
+ fix: "tfx setup",
2203
+ });
1630
2204
  issues++;
1631
2205
  }
1632
2206
  } catch {}
@@ -1636,10 +2210,18 @@ async function cmdDoctor(options = {}) {
1636
2210
  section(`Claude Code ${AMBER}●${RESET}`);
1637
2211
  const claudePath = which("claude");
1638
2212
  if (claudePath) {
1639
- addDoctorCheck(report, { name: "claude", status: "ok", path: claudePath });
2213
+ addDoctorCheck(report, {
2214
+ name: "claude",
2215
+ status: "ok",
2216
+ path: claudePath,
2217
+ });
1640
2218
  ok("설치됨");
1641
2219
  } else {
1642
- addDoctorCheck(report, { name: "claude", status: "missing", fix: "Claude Code를 설치한 뒤 `tfx doctor`를 다시 실행하세요." });
2220
+ addDoctorCheck(report, {
2221
+ name: "claude",
2222
+ status: "missing",
2223
+ fix: "Claude Code를 설치한 뒤 `tfx doctor`를 다시 실행하세요.",
2224
+ });
1643
2225
  fail("미설치 (필수)");
1644
2226
  issues++;
1645
2227
  }
@@ -1650,7 +2232,9 @@ async function cmdDoctor(options = {}) {
1650
2232
  const psmuxPath = which("psmux");
1651
2233
  if (psmuxPath) {
1652
2234
  ok("설치됨");
1653
- const psmuxSupport = probePsmuxSupport({ execFileSyncFn: execFileSync });
2235
+ const psmuxSupport = probePsmuxSupport({
2236
+ execFileSyncFn: execFileSync,
2237
+ });
1654
2238
  const supportOk = psmuxSupport.ok;
1655
2239
  info(`버전: ${psmuxSupport.version || "unknown"}`);
1656
2240
  if (!supportOk) {
@@ -1666,17 +2250,24 @@ async function cmdDoctor(options = {}) {
1666
2250
  });
1667
2251
  issues++;
1668
2252
  } else if (!psmuxSupport.recommended) {
1669
- warn(`권장 버전 미만: v${psmuxSupport.version || "unknown"} (권장: v${psmuxSupport.recommendedVersion}+)`);
2253
+ warn(
2254
+ `권장 버전 미만: v${psmuxSupport.version || "unknown"} (권장: v${psmuxSupport.recommendedVersion}+)`,
2255
+ );
1670
2256
  info(`업데이트 권장:\n${formatPsmuxUpdateGuidance(" ")}`);
1671
2257
  }
1672
2258
  if (psmuxSupport.missingOptionalCommands?.length > 0) {
1673
- info(`선택 capability 미지원: ${psmuxSupport.missingOptionalCommands.join(", ")} (detach-first hardening 경로에서만 사용)`);
2259
+ info(
2260
+ `선택 capability 미지원: ${psmuxSupport.missingOptionalCommands.join(", ")} (detach-first hardening 경로에서만 사용)`,
2261
+ );
1674
2262
  }
1675
2263
 
1676
2264
  // 기본 셸 확인: psmux 세션의 기본 셸이 PowerShell인지 cmd.exe인지
1677
2265
  let shellOk = false;
1678
2266
  try {
1679
- const defaultShell = execSync("psmux show-options -g default-shell 2>NUL", { encoding: "utf8", timeout: 3000 }).trim();
2267
+ const defaultShell = execSync(
2268
+ "psmux show-options -g default-shell 2>NUL",
2269
+ { encoding: "utf8", timeout: 3000 },
2270
+ ).trim();
1680
2271
  shellOk = /powershell|pwsh/i.test(defaultShell);
1681
2272
  } catch {
1682
2273
  // show-options 실패 시 pwsh/powershell 존재 여부로 판단
@@ -1684,65 +2275,116 @@ async function cmdDoctor(options = {}) {
1684
2275
  }
1685
2276
  if (supportOk && shellOk) {
1686
2277
  ok("기본 셸: PowerShell");
1687
- addDoctorCheck(report, { name: "psmux", status: "ok", path: psmuxPath, shell: "powershell" });
2278
+ addDoctorCheck(report, {
2279
+ name: "psmux",
2280
+ status: "ok",
2281
+ path: psmuxPath,
2282
+ shell: "powershell",
2283
+ });
1688
2284
  } else {
1689
2285
  if (fix) {
1690
2286
  // --fix: PowerShell로 자동 변경
1691
2287
  const pwshBin = which("pwsh") ? "pwsh" : "powershell.exe";
1692
2288
  try {
1693
- execSync(`psmux set-option -g default-shell "${pwshBin}"`, { timeout: 3000, stdio: "pipe" });
2289
+ execSync(`psmux set-option -g default-shell "${pwshBin}"`, {
2290
+ timeout: 3000,
2291
+ stdio: "pipe",
2292
+ });
1694
2293
  ok(`기본 셸 → ${pwshBin} 으로 변경 완료`);
1695
- addDoctorCheck(report, { name: "psmux", status: "ok", path: psmuxPath, shell: pwshBin, fixed: true });
2294
+ addDoctorCheck(report, {
2295
+ name: "psmux",
2296
+ status: "ok",
2297
+ path: psmuxPath,
2298
+ shell: pwshBin,
2299
+ fixed: true,
2300
+ });
1696
2301
  report.actions.push("psmux default-shell → " + pwshBin);
1697
2302
  } catch (e) {
1698
2303
  fail(`기본 셸 변경 실패: ${e.message}`);
1699
- addDoctorCheck(report, { name: "psmux", status: "issues", path: psmuxPath, shell: "cmd", fix: `psmux set-option -g default-shell "${pwshBin}"` });
2304
+ addDoctorCheck(report, {
2305
+ name: "psmux",
2306
+ status: "issues",
2307
+ path: psmuxPath,
2308
+ shell: "cmd",
2309
+ fix: `psmux set-option -g default-shell "${pwshBin}"`,
2310
+ });
1700
2311
  issues++;
1701
2312
  }
1702
2313
  } else {
1703
2314
  warn("기본 셸이 cmd.exe — headless 명령 실패 가능");
1704
- info(`수정: tfx doctor --fix 또는 psmux set-option -g default-shell "powershell.exe"`);
1705
- addDoctorCheck(report, { name: "psmux", status: "issues", path: psmuxPath, shell: "cmd", fix: "tfx doctor --fix" });
2315
+ info(
2316
+ `수정: tfx doctor --fix 또는 psmux set-option -g default-shell "powershell.exe"`,
2317
+ );
2318
+ addDoctorCheck(report, {
2319
+ name: "psmux",
2320
+ status: "issues",
2321
+ path: psmuxPath,
2322
+ shell: "cmd",
2323
+ fix: "tfx doctor --fix",
2324
+ });
1706
2325
  issues++;
1707
2326
  }
1708
2327
  }
1709
2328
  } else {
1710
2329
  info(`미설치 ${GRAY}(선택 — 멀티모델 병렬 실행에 필요)${RESET}`);
1711
2330
  info(`설치 방법:\n${formatPsmuxInstallGuidance(" ")}`);
1712
- addDoctorCheck(report, { name: "psmux", status: "skipped", detail: "미설치 (선택)", fix: "winget install marlocarlo.psmux" });
2331
+ addDoctorCheck(report, {
2332
+ name: "psmux",
2333
+ status: "skipped",
2334
+ detail: "미설치 (선택)",
2335
+ fix: "winget install marlocarlo.psmux",
2336
+ });
1713
2337
  }
1714
2338
  }
1715
2339
 
1716
- // 8. 스킬 설치 상태
1717
- section("Skills");
1718
- const skillsSrc = join(PKG_ROOT, "skills");
1719
- const skillsDst = join(CLAUDE_DIR, "skills");
1720
- if (existsSync(skillsSrc)) {
1721
- let installed = 0;
1722
- let total = 0;
1723
- const missing = [];
1724
- for (const name of readdirSync(skillsSrc)) {
1725
- if (!existsSync(join(skillsSrc, name, "SKILL.md"))) continue;
1726
- total++;
1727
- if (existsSync(join(skillsDst, name, "SKILL.md"))) {
1728
- installed++;
1729
- } else {
1730
- missing.push(name);
2340
+ // 8. 스킬 설치 상태
2341
+ section("Skills");
2342
+ const skillsSrc = join(PKG_ROOT, "skills");
2343
+ const skillsDst = join(CLAUDE_DIR, "skills");
2344
+ if (existsSync(skillsSrc)) {
2345
+ let installed = 0;
2346
+ let total = 0;
2347
+ const missing = [];
2348
+ for (const name of readdirSync(skillsSrc)) {
2349
+ if (!existsSync(join(skillsSrc, name, "SKILL.md"))) continue;
2350
+ total++;
2351
+ if (existsSync(join(skillsDst, name, "SKILL.md"))) {
2352
+ installed++;
2353
+ } else {
2354
+ missing.push(name);
2355
+ }
1731
2356
  }
1732
- }
1733
2357
  if (installed === total) {
1734
- addDoctorCheck(report, { name: "skills", status: "ok", installed, total });
2358
+ addDoctorCheck(report, {
2359
+ name: "skills",
2360
+ status: "ok",
2361
+ installed,
2362
+ total,
2363
+ });
1735
2364
  ok(`${installed}/${total}개 설치됨`);
1736
2365
  } else {
1737
- addDoctorCheck(report, { name: "skills", status: "missing", installed, total, missing, fix: "tfx setup" });
2366
+ addDoctorCheck(report, {
2367
+ name: "skills",
2368
+ status: "missing",
2369
+ installed,
2370
+ total,
2371
+ missing,
2372
+ fix: "tfx setup",
2373
+ });
1738
2374
  warn(`${installed}/${total}개 설치됨 — 미설치: ${missing.join(", ")}`);
1739
2375
  info("triflux setup으로 동기화 가능");
1740
2376
  issues++;
1741
2377
  }
1742
2378
  } else {
1743
- addDoctorCheck(report, { name: "skills", status: "missing", installed: 0, total: 0, fix: "패키지 skills 디렉토리를 확인하세요." });
1744
- }
1745
-
2379
+ addDoctorCheck(report, {
2380
+ name: "skills",
2381
+ status: "missing",
2382
+ installed: 0,
2383
+ total: 0,
2384
+ fix: "패키지 skills 디렉토리를 확인하세요.",
2385
+ });
2386
+ }
2387
+
1746
2388
  // Stale 스킬 체크
1747
2389
  const staleSkills = [];
1748
2390
  const userSkillsDir = join(CLAUDE_DIR, "skills");
@@ -1762,7 +2404,12 @@ async function cmdDoctor(options = {}) {
1762
2404
  if (staleSkills.length > 0) {
1763
2405
  warn(`구형 스킬 ${staleSkills.length}개 감지: ${staleSkills.join(", ")}`);
1764
2406
  info("제거: tfx setup 또는 tfx update");
1765
- addDoctorCheck(report, { name: "stale-skills", status: "issues", skills: staleSkills, fix: "tfx setup" });
2407
+ addDoctorCheck(report, {
2408
+ name: "stale-skills",
2409
+ status: "issues",
2410
+ skills: staleSkills,
2411
+ fix: "tfx setup",
2412
+ });
1766
2413
  issues++;
1767
2414
  } else {
1768
2415
  addDoctorCheck(report, { name: "stale-skills", status: "ok" });
@@ -1774,776 +2421,1041 @@ async function cmdDoctor(options = {}) {
1774
2421
  if (existsSync(pluginsFile)) {
1775
2422
  const content = readFileSync(pluginsFile, "utf8");
1776
2423
  if (content.includes("triflux")) {
1777
- addDoctorCheck(report, { name: "plugin", status: "ok", path: pluginsFile });
2424
+ addDoctorCheck(report, {
2425
+ name: "plugin",
2426
+ status: "ok",
2427
+ path: pluginsFile,
2428
+ });
1778
2429
  ok("triflux 플러그인 등록됨");
1779
2430
  } else {
1780
- addDoctorCheck(report, { name: "plugin", status: "missing", path: pluginsFile, optional: true, fix: "/plugin marketplace add <repo-url>" });
2431
+ addDoctorCheck(report, {
2432
+ name: "plugin",
2433
+ status: "missing",
2434
+ path: pluginsFile,
2435
+ optional: true,
2436
+ fix: "/plugin marketplace add <repo-url>",
2437
+ });
1781
2438
  warn("triflux 플러그인 미등록 — npm 단독 사용 중");
1782
2439
  info("플러그인 등록: /plugin marketplace add <repo-url>");
1783
2440
  }
1784
2441
  } else {
1785
- addDoctorCheck(report, { name: "plugin", status: "unavailable", optional: true });
2442
+ addDoctorCheck(report, {
2443
+ name: "plugin",
2444
+ status: "unavailable",
2445
+ optional: true,
2446
+ });
1786
2447
  info("플러그인 시스템 감지 안 됨 — npm 단독 사용");
1787
2448
  }
1788
2449
 
1789
- // 10. MCP 인벤토리
1790
- section("MCP Inventory");
1791
- const mcpCache = join(CLAUDE_DIR, "cache", "mcp-inventory.json");
1792
- if (existsSync(mcpCache)) {
1793
- try {
1794
- const inv = JSON.parse(readFileSync(mcpCache, "utf8"));
2450
+ // 10. MCP 인벤토리
2451
+ section("MCP Inventory");
2452
+ const mcpCache = join(CLAUDE_DIR, "cache", "mcp-inventory.json");
2453
+ if (existsSync(mcpCache)) {
2454
+ try {
2455
+ const inv = JSON.parse(readFileSync(mcpCache, "utf8"));
2456
+ addDoctorCheck(report, {
2457
+ name: "mcp-inventory",
2458
+ status: "ok",
2459
+ path: mcpCache,
2460
+ codex_servers: inv.codex?.servers?.length || 0,
2461
+ gemini_servers: inv.gemini?.servers?.length || 0,
2462
+ });
2463
+ ok(`캐시 존재 (${inv.timestamp})`);
2464
+ if (inv.codex?.servers?.length) {
2465
+ const names = inv.codex.servers.map((s) => s.name).join(", ");
2466
+ info(`Codex: ${inv.codex.servers.length}개 서버 (${names})`);
2467
+ }
2468
+ if (inv.gemini?.servers?.length) {
2469
+ const names = inv.gemini.servers.map((s) => s.name).join(", ");
2470
+ info(`Gemini: ${inv.gemini.servers.length}개 서버 (${names})`);
2471
+ }
2472
+ } catch {
2473
+ addDoctorCheck(report, {
2474
+ name: "mcp-inventory",
2475
+ status: "invalid",
2476
+ path: mcpCache,
2477
+ fix: `node ${join(PKG_ROOT, "scripts", "mcp-check.mjs")}`,
2478
+ });
2479
+ warn("캐시 파일 파싱 실패");
2480
+ }
2481
+ } else {
1795
2482
  addDoctorCheck(report, {
1796
2483
  name: "mcp-inventory",
1797
- status: "ok",
2484
+ status: "missing",
1798
2485
  path: mcpCache,
1799
- codex_servers: inv.codex?.servers?.length || 0,
1800
- gemini_servers: inv.gemini?.servers?.length || 0,
2486
+ fix: `node ${join(PKG_ROOT, "scripts", "mcp-check.mjs")}`,
1801
2487
  });
1802
- ok(`캐시 존재 (${inv.timestamp})`);
1803
- if (inv.codex?.servers?.length) {
1804
- const names = inv.codex.servers.map(s => s.name).join(", ");
1805
- info(`Codex: ${inv.codex.servers.length}개 서버 (${names})`);
1806
- }
1807
- if (inv.gemini?.servers?.length) {
1808
- const names = inv.gemini.servers.map(s => s.name).join(", ");
1809
- info(`Gemini: ${inv.gemini.servers.length}개 서버 (${names})`);
1810
- }
1811
- } catch {
1812
- addDoctorCheck(report, { name: "mcp-inventory", status: "invalid", path: mcpCache, fix: `node ${join(PKG_ROOT, "scripts", "mcp-check.mjs")}` });
1813
- warn("캐시 파일 파싱 실패");
2488
+ warn("캐시 없음 — 다음 세션 시작 시 자동 생성");
2489
+ info(`수동: node ${join(PKG_ROOT, "scripts", "mcp-check.mjs")}`);
1814
2490
  }
1815
- } else {
1816
- addDoctorCheck(report, { name: "mcp-inventory", status: "missing", path: mcpCache, fix: `node ${join(PKG_ROOT, "scripts", "mcp-check.mjs")}` });
1817
- warn("캐시 없음 — 다음 세션 시작 시 자동 생성");
1818
- info(`수동: node ${join(PKG_ROOT, "scripts", "mcp-check.mjs")}`);
1819
- }
1820
2491
 
1821
- // 9.5. Phase 1 웜업 캐시
1822
- section("Warmup Cache");
1823
- try {
1824
- const { verifyCaches } = await import("../scripts/cache-doctor.mjs");
1825
- const cacheVerification = verifyCaches({ cwd: process.cwd() });
1826
- const brokenCaches = cacheVerification.results.filter((result) => result.status !== "ok");
2492
+ // 9.5. Phase 1 웜업 캐시
2493
+ section("Warmup Cache");
2494
+ try {
2495
+ const { verifyCaches } = await import("../scripts/cache-doctor.mjs");
2496
+ const cacheVerification = verifyCaches({ cwd: process.cwd() });
2497
+ const brokenCaches = cacheVerification.results.filter(
2498
+ (result) => result.status !== "ok",
2499
+ );
1827
2500
 
1828
- addDoctorCheck(report, {
1829
- name: "warmup-cache",
1830
- status: cacheVerification.ok ? "ok" : "issues",
1831
- files: cacheVerification.results.map((result) => ({
1832
- target: result.target,
1833
- status: result.status,
1834
- path: result.file,
1835
- })),
1836
- ...(cacheVerification.ok ? {} : { fix: "tfx doctor --fix" }),
1837
- });
2501
+ addDoctorCheck(report, {
2502
+ name: "warmup-cache",
2503
+ status: cacheVerification.ok ? "ok" : "issues",
2504
+ files: cacheVerification.results.map((result) => ({
2505
+ target: result.target,
2506
+ status: result.status,
2507
+ path: result.file,
2508
+ })),
2509
+ ...(cacheVerification.ok ? {} : { fix: "tfx doctor --fix" }),
2510
+ });
1838
2511
 
1839
- if (brokenCaches.length === 0) {
1840
- ok("4개 웜업 캐시 정상");
1841
- } else {
1842
- warn(`${brokenCaches.length}개 웜업 캐시 이슈 발견`);
1843
- for (const entry of brokenCaches) {
1844
- info(`${entry.target}: ${entry.status}`);
2512
+ if (brokenCaches.length === 0) {
2513
+ ok("4개 웜업 캐시 정상");
2514
+ } else {
2515
+ warn(`${brokenCaches.length}개 웜업 캐시 이슈 발견`);
2516
+ for (const entry of brokenCaches) {
2517
+ info(`${entry.target}: ${entry.status}`);
2518
+ }
2519
+ if (!fix) issues += brokenCaches.length;
1845
2520
  }
1846
- if (!fix) issues += brokenCaches.length;
2521
+ } catch (error) {
2522
+ addDoctorCheck(report, {
2523
+ name: "warmup-cache",
2524
+ status: "invalid",
2525
+ fix: "node scripts/cache-doctor.mjs --fix",
2526
+ });
2527
+ warn(`웜업 캐시 검사 실패: ${error.message}`);
2528
+ issues++;
1847
2529
  }
1848
- } catch (error) {
1849
- addDoctorCheck(report, {
1850
- name: "warmup-cache",
1851
- status: "invalid",
1852
- fix: "node scripts/cache-doctor.mjs --fix",
1853
- });
1854
- warn(`웜업 캐시 검사 실패: ${error.message}`);
1855
- issues++;
1856
- }
1857
2530
 
1858
- // 11. CLI 이슈 트래커
1859
- section("CLI Issues");
1860
- const issuesFile = join(CLAUDE_DIR, "cache", "cli-issues.jsonl");
1861
- if (existsSync(issuesFile)) {
1862
- try {
1863
- const lines = readFileSync(issuesFile, "utf8").trim().split("\n").filter(Boolean);
1864
- const entries = lines.map(l => { try { return JSON.parse(l); } catch { return null; } }).filter(Boolean);
1865
- const unresolved = entries.filter(e => !e.resolved);
2531
+ // 11. CLI 이슈 트래커
2532
+ section("CLI Issues");
2533
+ const issuesFile = join(CLAUDE_DIR, "cache", "cli-issues.jsonl");
2534
+ if (existsSync(issuesFile)) {
2535
+ try {
2536
+ const lines = readFileSync(issuesFile, "utf8")
2537
+ .trim()
2538
+ .split("\n")
2539
+ .filter(Boolean);
2540
+ const entries = lines
2541
+ .map((l) => {
2542
+ try {
2543
+ return JSON.parse(l);
2544
+ } catch {
2545
+ return null;
2546
+ }
2547
+ })
2548
+ .filter(Boolean);
2549
+ const unresolved = entries.filter((e) => !e.resolved);
1866
2550
 
1867
- if (unresolved.length === 0) {
1868
- addDoctorCheck(report, { name: "cli-issues", status: "ok", path: issuesFile, unresolved: 0 });
1869
- ok("미해결 이슈 없음");
1870
- } else {
1871
- // 패턴별 그룹핑
1872
- const groups = {};
1873
- for (const e of unresolved) {
1874
- const key = `${e.cli}:${e.pattern}`;
1875
- if (!groups[key]) groups[key] = { ...e, count: 0 };
1876
- groups[key].count++;
1877
- if (e.ts > groups[key].ts) { groups[key].ts = e.ts; groups[key].snippet = e.snippet; }
1878
- }
1879
-
1880
- // semver 비교 (lexicographic 비교 버그 방지)
1881
- function semverGte(a, b) {
1882
- const pa = a.split('.').map(Number);
1883
- const pb = b.split('.').map(Number);
1884
- for (let i = 0; i < 3; i++) {
1885
- if ((pa[i] || 0) > (pb[i] || 0)) return true;
1886
- if ((pa[i] || 0) < (pb[i] || 0)) return false;
2551
+ if (unresolved.length === 0) {
2552
+ addDoctorCheck(report, {
2553
+ name: "cli-issues",
2554
+ status: "ok",
2555
+ path: issuesFile,
2556
+ unresolved: 0,
2557
+ });
2558
+ ok("미해결 이슈 없음");
2559
+ } else {
2560
+ // 패턴별 그룹핑
2561
+ const groups = {};
2562
+ for (const e of unresolved) {
2563
+ const key = `${e.cli}:${e.pattern}`;
2564
+ if (!groups[key]) groups[key] = { ...e, count: 0 };
2565
+ groups[key].count++;
2566
+ if (e.ts > groups[key].ts) {
2567
+ groups[key].ts = e.ts;
2568
+ groups[key].snippet = e.snippet;
2569
+ }
1887
2570
  }
1888
- return true;
1889
- }
1890
2571
 
1891
- // 알려진 해결 버전 (패턴별 수정된 triflux 버전)
1892
- const KNOWN_FIXES = {
1893
- "gemini:deprecated_flag": "1.8.9", // -p → --prompt
1894
- };
2572
+ // semver 비교 (lexicographic 비교 버그 방지)
2573
+ function semverGte(a, b) {
2574
+ const pa = a.split(".").map(Number);
2575
+ const pb = b.split(".").map(Number);
2576
+ for (let i = 0; i < 3; i++) {
2577
+ if ((pa[i] || 0) > (pb[i] || 0)) return true;
2578
+ if ((pa[i] || 0) < (pb[i] || 0)) return false;
2579
+ }
2580
+ return true;
2581
+ }
1895
2582
 
1896
- const currentVer = JSON.parse(readFileSync(join(PKG_ROOT, "package.json"), "utf8")).version;
1897
- let cleaned = 0;
2583
+ // 알려진 해결 버전 (패턴별 수정된 triflux 버전)
2584
+ const KNOWN_FIXES = {
2585
+ "gemini:deprecated_flag": "1.8.9", // -p → --prompt
2586
+ };
1898
2587
 
1899
- for (const [key, g] of Object.entries(groups)) {
1900
- const fixVer = KNOWN_FIXES[key];
1901
- if (fixVer && semverGte(currentVer, fixVer)) {
1902
- // 해결된 이슈 — 자동 정리
1903
- cleaned += g.count;
1904
- continue;
1905
- }
1906
- const age = Date.now() - g.ts;
1907
- const ago = age < 3600000 ? `${Math.round(age / 60000)}분 전` :
1908
- age < 86400000 ? `${Math.round(age / 3600000)}시간 전` :
1909
- `${Math.round(age / 86400000)}일 전`;
1910
- const sev = g.severity === "error" ? `${RED}ERROR${RESET}` : `${YELLOW}WARN${RESET}`;
1911
- warn(`[${sev}] ${g.cli}/${g.pattern} x${g.count} (최근: ${ago})`);
1912
- if (g.snippet) info(` ${g.snippet.substring(0, 120)}`);
1913
- if (fixVer) info(` 해결: triflux >= v${fixVer} (npm update -g triflux)`);
1914
- issues++;
1915
- }
2588
+ const currentVer = JSON.parse(
2589
+ readFileSync(join(PKG_ROOT, "package.json"), "utf8"),
2590
+ ).version;
2591
+ let cleaned = 0;
1916
2592
 
1917
- // 해결된 이슈 자동 정리
1918
- if (cleaned > 0) {
1919
- const remaining = entries.filter(e => {
1920
- const key = `${e.cli}:${e.pattern}`;
2593
+ for (const [key, g] of Object.entries(groups)) {
1921
2594
  const fixVer = KNOWN_FIXES[key];
1922
- return !(fixVer && semverGte(currentVer, fixVer));
2595
+ if (fixVer && semverGte(currentVer, fixVer)) {
2596
+ // 해결된 이슈 — 자동 정리
2597
+ cleaned += g.count;
2598
+ continue;
2599
+ }
2600
+ const age = Date.now() - g.ts;
2601
+ const ago =
2602
+ age < 3600000
2603
+ ? `${Math.round(age / 60000)}분 전`
2604
+ : age < 86400000
2605
+ ? `${Math.round(age / 3600000)}시간 전`
2606
+ : `${Math.round(age / 86400000)}일 전`;
2607
+ const sev =
2608
+ g.severity === "error"
2609
+ ? `${RED}ERROR${RESET}`
2610
+ : `${YELLOW}WARN${RESET}`;
2611
+ warn(`[${sev}] ${g.cli}/${g.pattern} x${g.count} (최근: ${ago})`);
2612
+ if (g.snippet) info(` ${g.snippet.substring(0, 120)}`);
2613
+ if (fixVer)
2614
+ info(` 해결: triflux >= v${fixVer} (npm update -g triflux)`);
2615
+ issues++;
2616
+ }
2617
+
2618
+ // 해결된 이슈 자동 정리
2619
+ if (cleaned > 0) {
2620
+ const remaining = entries.filter((e) => {
2621
+ const key = `${e.cli}:${e.pattern}`;
2622
+ const fixVer = KNOWN_FIXES[key];
2623
+ return !(fixVer && semverGte(currentVer, fixVer));
2624
+ });
2625
+ writeFileSync(
2626
+ issuesFile,
2627
+ remaining.map((e) => JSON.stringify(e)).join("\n") +
2628
+ (remaining.length ? "\n" : ""),
2629
+ );
2630
+ ok(`${cleaned}개 해결된 이슈 자동 정리됨`);
2631
+ }
2632
+ addDoctorCheck(report, {
2633
+ name: "cli-issues",
2634
+ status: unresolved.length === 0 ? "ok" : "issues",
2635
+ path: issuesFile,
2636
+ unresolved: unresolved.length,
1923
2637
  });
1924
- writeFileSync(issuesFile, remaining.map(e => JSON.stringify(e)).join("\n") + (remaining.length ? "\n" : ""));
1925
- ok(`${cleaned}개 해결된 이슈 자동 정리됨`);
1926
2638
  }
1927
- addDoctorCheck(report, { name: "cli-issues", status: unresolved.length === 0 ? "ok" : "issues", path: issuesFile, unresolved: unresolved.length });
2639
+ } catch (e) {
2640
+ addDoctorCheck(report, {
2641
+ name: "cli-issues",
2642
+ status: "invalid",
2643
+ path: issuesFile,
2644
+ fix: "cli-issues.jsonl 형식을 확인하세요.",
2645
+ });
2646
+ warn(`이슈 파일 읽기 실패: ${e.message}`);
1928
2647
  }
1929
- } catch (e) {
1930
- addDoctorCheck(report, { name: "cli-issues", status: "invalid", path: issuesFile, fix: "cli-issues.jsonl 형식을 확인하세요." });
1931
- warn(`이슈 파일 읽기 실패: ${e.message}`);
2648
+ } else {
2649
+ addDoctorCheck(report, {
2650
+ name: "cli-issues",
2651
+ status: "ok",
2652
+ path: issuesFile,
2653
+ unresolved: 0,
2654
+ });
2655
+ ok("이슈 로그 없음 (정상)");
1932
2656
  }
1933
- } else {
1934
- addDoctorCheck(report, { name: "cli-issues", status: "ok", path: issuesFile, unresolved: 0 });
1935
- ok("이슈 로그 없음 (정상)");
1936
- }
1937
-
1938
- // 12. Team Sessions
1939
- section("Team Sessions");
1940
- const teamSessionReport = inspectTeamSessions();
1941
- if (!teamSessionReport.mux) {
1942
- addDoctorCheck(report, { name: "team-sessions", status: "skipped", detail: "tmux/psmux unavailable" });
1943
- info("tmux/psmux 미감지 — 팀 세션 검사 건너뜀");
1944
- } else if (teamSessionReport.sessions.length === 0) {
1945
- addDoctorCheck(report, { name: "team-sessions", status: "ok", multiplexer: teamSessionReport.mux, sessions: 0 });
1946
- ok(`활성 팀 세션 없음 ${DIM}(${teamSessionReport.mux})${RESET}`);
1947
- } else {
1948
- addDoctorCheck(report, {
1949
- name: "team-sessions",
1950
- status: teamSessionReport.sessions.some((session) => session.stale) ? "issues" : "ok",
1951
- multiplexer: teamSessionReport.mux,
1952
- sessions: teamSessionReport.sessions.map((session) => ({
1953
- name: session.sessionName,
1954
- attached: session.attachedCount,
1955
- age_sec: session.ageSec,
1956
- stale: session.stale,
1957
- })),
1958
- });
1959
- info(`multiplexer: ${teamSessionReport.mux}`);
1960
2657
 
1961
- for (const session of teamSessionReport.sessions) {
1962
- const attachedLabel = session.attachedCount == null ? "?" : `${session.attachedCount}`;
1963
- const ageLabel = formatElapsedAge(session.ageSec);
2658
+ // 12. Team Sessions
2659
+ section("Team Sessions");
2660
+ const teamSessionReport = inspectTeamSessions();
2661
+ if (!teamSessionReport.mux) {
2662
+ addDoctorCheck(report, {
2663
+ name: "team-sessions",
2664
+ status: "skipped",
2665
+ detail: "tmux/psmux unavailable",
2666
+ });
2667
+ info("tmux/psmux 미감지 — 팀 세션 검사 건너뜀");
2668
+ } else if (teamSessionReport.sessions.length === 0) {
2669
+ addDoctorCheck(report, {
2670
+ name: "team-sessions",
2671
+ status: "ok",
2672
+ multiplexer: teamSessionReport.mux,
2673
+ sessions: 0,
2674
+ });
2675
+ ok(`활성 팀 세션 없음 ${DIM}(${teamSessionReport.mux})${RESET}`);
2676
+ } else {
2677
+ addDoctorCheck(report, {
2678
+ name: "team-sessions",
2679
+ status: teamSessionReport.sessions.some((session) => session.stale)
2680
+ ? "issues"
2681
+ : "ok",
2682
+ multiplexer: teamSessionReport.mux,
2683
+ sessions: teamSessionReport.sessions.map((session) => ({
2684
+ name: session.sessionName,
2685
+ attached: session.attachedCount,
2686
+ age_sec: session.ageSec,
2687
+ stale: session.stale,
2688
+ })),
2689
+ });
2690
+ info(`multiplexer: ${teamSessionReport.mux}`);
1964
2691
 
1965
- if (session.stale) {
1966
- warn(`${session.sessionName}: stale 추정 (attach=${attachedLabel}, 경과=${ageLabel})`);
1967
- } else {
1968
- ok(`${session.sessionName}: 정상 (attach=${attachedLabel}, 경과=${ageLabel})`);
1969
- }
2692
+ for (const session of teamSessionReport.sessions) {
2693
+ const attachedLabel =
2694
+ session.attachedCount == null ? "?" : `${session.attachedCount}`;
2695
+ const ageLabel = formatElapsedAge(session.ageSec);
1970
2696
 
1971
- if (session.createdAt == null) {
1972
- info(`${session.sessionName}: session_created 파싱 실패${session.createdRaw ? ` (${session.createdRaw})` : ""}`);
2697
+ if (session.stale) {
2698
+ warn(
2699
+ `${session.sessionName}: stale 추정 (attach=${attachedLabel}, 경과=${ageLabel})`,
2700
+ );
2701
+ } else {
2702
+ ok(
2703
+ `${session.sessionName}: 정상 (attach=${attachedLabel}, 경과=${ageLabel})`,
2704
+ );
2705
+ }
2706
+
2707
+ if (session.createdAt == null) {
2708
+ info(
2709
+ `${session.sessionName}: session_created 파싱 실패${session.createdRaw ? ` (${session.createdRaw})` : ""}`,
2710
+ );
2711
+ }
1973
2712
  }
1974
- }
1975
2713
 
1976
- const staleSessions = teamSessionReport.sessions.filter((session) => session.stale);
1977
- if (staleSessions.length > 0) {
1978
- if (fix) {
1979
- const cleanupResult = await cleanupStaleTeamSessions(staleSessions);
1980
- issues += cleanupResult.failed;
1981
- } else {
1982
- info("정리: tfx doctor --fix");
1983
- issues += staleSessions.length;
2714
+ const staleSessions = teamSessionReport.sessions.filter(
2715
+ (session) => session.stale,
2716
+ );
2717
+ if (staleSessions.length > 0) {
2718
+ if (fix) {
2719
+ const cleanupResult = await cleanupStaleTeamSessions(staleSessions);
2720
+ issues += cleanupResult.failed;
2721
+ } else {
2722
+ info("정리: tfx doctor --fix");
2723
+ issues += staleSessions.length;
2724
+ }
1984
2725
  }
1985
2726
  }
1986
- }
1987
-
1988
- // 13. OMC stale team 상태
1989
- section("OMC Stale Teams");
1990
- const omcTeamReport = inspectStaleOmcTeams({
1991
- startDir: process.cwd(),
1992
- maxAgeMs: STALE_TEAM_MAX_AGE_SEC * 1000,
1993
- liveSessionNames: teamSessionReport.sessions.map((session) => session.sessionName),
1994
- });
1995
- if (!omcTeamReport.stateRoot && !omcTeamReport.teamsRoot) {
1996
- addDoctorCheck(report, { name: "omc-stale-teams", status: "skipped" });
1997
- info(".omc/state 및 ~/.claude/teams 없음 — 검사 건너뜀");
1998
- } else if (omcTeamReport.entries.length === 0) {
1999
- addDoctorCheck(report, { name: "omc-stale-teams", status: "ok", entries: 0 });
2000
- const roots = [omcTeamReport.stateRoot, omcTeamReport.teamsRoot].filter(Boolean).join(", ");
2001
- ok(`stale team 없음 ${DIM}(${roots})${RESET}`);
2002
- } else {
2003
- addDoctorCheck(report, { name: "omc-stale-teams", status: "issues", entries: omcTeamReport.entries.length, fix: "tfx doctor --fix" });
2004
- warn(`${omcTeamReport.entries.length}개 stale team 발견`);
2005
2727
 
2006
- for (const entry of omcTeamReport.entries) {
2007
- const ageLabel = formatElapsedAge(entry.ageSec);
2008
- const scopeLabel = entry.scope === "root"
2009
- ? "root-state"
2010
- : entry.scope === "claude_team"
2011
- ? `claude-team:${entry.teamName || entry.sessionId}`
2012
- : entry.sessionId;
2013
- warn(`${scopeLabel}: stale team (경과=${ageLabel}, 프로세스 없음)`);
2014
- if (entry.teamName) info(`팀: ${entry.teamName}`);
2015
- info(`파일: ${entry.stateFile || entry.cleanupPath}`);
2016
- }
2728
+ // 13. OMC stale team 상태
2729
+ section("OMC Stale Teams");
2730
+ const omcTeamReport = inspectStaleOmcTeams({
2731
+ startDir: process.cwd(),
2732
+ maxAgeMs: STALE_TEAM_MAX_AGE_SEC * 1000,
2733
+ liveSessionNames: teamSessionReport.sessions.map(
2734
+ (session) => session.sessionName,
2735
+ ),
2736
+ });
2737
+ if (!omcTeamReport.stateRoot && !omcTeamReport.teamsRoot) {
2738
+ addDoctorCheck(report, { name: "omc-stale-teams", status: "skipped" });
2739
+ info(".omc/state 및 ~/.claude/teams 없음 — 검사 건너뜀");
2740
+ } else if (omcTeamReport.entries.length === 0) {
2741
+ addDoctorCheck(report, {
2742
+ name: "omc-stale-teams",
2743
+ status: "ok",
2744
+ entries: 0,
2745
+ });
2746
+ const roots = [omcTeamReport.stateRoot, omcTeamReport.teamsRoot]
2747
+ .filter(Boolean)
2748
+ .join(", ");
2749
+ ok(`stale team 없음 ${DIM}(${roots})${RESET}`);
2750
+ } else {
2751
+ addDoctorCheck(report, {
2752
+ name: "omc-stale-teams",
2753
+ status: "issues",
2754
+ entries: omcTeamReport.entries.length,
2755
+ fix: "tfx doctor --fix",
2756
+ });
2757
+ warn(`${omcTeamReport.entries.length}개 stale team 발견`);
2017
2758
 
2018
- if (fix) {
2019
- const cleanupResult = await cleanupStaleOmcTeams(omcTeamReport.entries);
2020
- for (const result of cleanupResult.results) {
2021
- if (result.ok) {
2022
- const label = result.entry.scope === "root"
2759
+ for (const entry of omcTeamReport.entries) {
2760
+ const ageLabel = formatElapsedAge(entry.ageSec);
2761
+ const scopeLabel =
2762
+ entry.scope === "root"
2023
2763
  ? "root-state"
2024
- : result.entry.scope === "claude_team"
2025
- ? (result.entry.teamName || result.entry.sessionId)
2026
- : result.entry.sessionId;
2027
- ok(`stale team 정리: ${label}`);
2028
- } else {
2029
- const label = result.entry.scope === "root"
2030
- ? "root-state"
2031
- : result.entry.scope === "claude_team"
2032
- ? (result.entry.teamName || result.entry.sessionId)
2033
- : result.entry.sessionId;
2034
- fail(`stale team 정리 실패: ${label} — ${result.error.message}`);
2035
- }
2764
+ : entry.scope === "claude_team"
2765
+ ? `claude-team:${entry.teamName || entry.sessionId}`
2766
+ : entry.sessionId;
2767
+ warn(`${scopeLabel}: stale team (경과=${ageLabel}, 프로세스 없음)`);
2768
+ if (entry.teamName) info(`팀: ${entry.teamName}`);
2769
+ info(`파일: ${entry.stateFile || entry.cleanupPath}`);
2036
2770
  }
2037
- issues += cleanupResult.failed;
2038
- } else {
2039
- info("정리: tfx doctor --fix");
2040
- issues += omcTeamReport.entries.length;
2041
- }
2042
- }
2043
2771
 
2044
- // 12.5. 고아 node.exe 프로세스 정리 (Windows)
2045
- section("Orphan Processes");
2046
- if (process.platform === "win32") {
2047
- try {
2048
- const { cleanupOrphanNodeProcesses } = await import("../hub/lib/process-utils.mjs");
2049
2772
  if (fix) {
2050
- const { killed, remaining } = cleanupOrphanNodeProcesses();
2051
- if (killed > 0) {
2052
- warn(`고아 node.exe ${killed}개 정리 완료 (남은 프로세스: ${remaining})`);
2053
- } else {
2054
- ok(`고아 node.exe 없음 (활성: ${remaining})`);
2773
+ const cleanupResult = await cleanupStaleOmcTeams(omcTeamReport.entries);
2774
+ for (const result of cleanupResult.results) {
2775
+ if (result.ok) {
2776
+ const label =
2777
+ result.entry.scope === "root"
2778
+ ? "root-state"
2779
+ : result.entry.scope === "claude_team"
2780
+ ? result.entry.teamName || result.entry.sessionId
2781
+ : result.entry.sessionId;
2782
+ ok(`stale team 정리: ${label}`);
2783
+ } else {
2784
+ const label =
2785
+ result.entry.scope === "root"
2786
+ ? "root-state"
2787
+ : result.entry.scope === "claude_team"
2788
+ ? result.entry.teamName || result.entry.sessionId
2789
+ : result.entry.sessionId;
2790
+ fail(`stale team 정리 실패: ${label} — ${result.error.message}`);
2791
+ }
2055
2792
  }
2793
+ issues += cleanupResult.failed;
2056
2794
  } else {
2057
- // --fix 없이는 개수만 보고
2058
- const { execSync: execSyncDoctor } = await import("node:child_process");
2059
- const countStr = execSyncDoctor(
2060
- `powershell -NoProfile -WindowStyle Hidden -Command "(Get-Process node -ErrorAction SilentlyContinue).Count"`,
2061
- { encoding: "utf8", timeout: 5000 },
2062
- ).trim();
2063
- const count = Number.parseInt(countStr, 10) || 0;
2064
- if (count > 20) {
2065
- warn(`node.exe ${count}개 실행 중 (고아 포함 가능). 정리: tfx doctor --fix`);
2066
- issues++;
2795
+ info("정리: tfx doctor --fix");
2796
+ issues += omcTeamReport.entries.length;
2797
+ }
2798
+ }
2799
+
2800
+ // 12.5. 고아 node.exe 프로세스 정리 (Windows)
2801
+ section("Orphan Processes");
2802
+ if (process.platform === "win32") {
2803
+ try {
2804
+ const { cleanupOrphanNodeProcesses } = await import(
2805
+ "../hub/lib/process-utils.mjs"
2806
+ );
2807
+ if (fix) {
2808
+ const { killed, remaining } = cleanupOrphanNodeProcesses();
2809
+ if (killed > 0) {
2810
+ warn(
2811
+ `고아 node.exe ${killed}개 정리 완료 (남은 프로세스: ${remaining})`,
2812
+ );
2813
+ } else {
2814
+ ok(`고아 node.exe 없음 (활성: ${remaining})`);
2815
+ }
2067
2816
  } else {
2068
- ok(`node.exe ${count}개 (정상 범위)`);
2817
+ // --fix 없이는 개수만 보고
2818
+ const { execSync: execSyncDoctor } = await import(
2819
+ "node:child_process"
2820
+ );
2821
+ const countStr = execSyncDoctor(
2822
+ `powershell -NoProfile -WindowStyle Hidden -Command "(Get-Process node -ErrorAction SilentlyContinue).Count"`,
2823
+ { encoding: "utf8", timeout: 5000 },
2824
+ ).trim();
2825
+ const count = Number.parseInt(countStr, 10) || 0;
2826
+ if (count > 20) {
2827
+ warn(
2828
+ `node.exe ${count}개 실행 중 (고아 포함 가능). 정리: tfx doctor --fix`,
2829
+ );
2830
+ issues++;
2831
+ } else {
2832
+ ok(`node.exe ${count}개 (정상 범위)`);
2833
+ }
2069
2834
  }
2835
+ } catch (e) {
2836
+ info(`고아 프로세스 검사 실패: ${e.message}`);
2070
2837
  }
2071
- } catch (e) {
2072
- info(`고아 프로세스 검사 실패: ${e.message}`);
2838
+ } else {
2839
+ ok("Windows 전용 검사 건너뜀");
2073
2840
  }
2074
- } else {
2075
- ok("Windows 전용 검사 — 건너뜀");
2076
- }
2077
2841
 
2078
- // 14. Stale Teams (Claude teams/ + tasks/ 자동 감지)
2079
- section("Stale Teams");
2080
- const teamsDir = join(CLAUDE_DIR, "teams");
2081
- const _tasksDir = join(CLAUDE_DIR, "tasks");
2082
- if (existsSync(teamsDir)) {
2083
- try {
2084
- const teamDirs = readdirSync(teamsDir).filter(d => {
2085
- try { return statSync(join(teamsDir, d)).isDirectory(); } catch { return false; }
2086
- });
2087
- if (teamDirs.length === 0) {
2088
- addDoctorCheck(report, { name: "stale-teams", status: "ok", entries: 0 });
2089
- ok("잔존 팀 없음");
2090
- } else {
2091
- const nowMs = Date.now();
2092
- const staleMaxAgeMs = STALE_TEAM_MAX_AGE_SEC * 1000;
2093
- const staleTeams = [];
2094
- const activeTeams = [];
2095
-
2096
- for (const d of teamDirs) {
2097
- const teamPath = join(teamsDir, d);
2098
- const configPath = join(teamPath, "config.json");
2099
- let teamConfig = null;
2100
- let configMtimeMs = null;
2101
- let missingConfig = false;
2102
-
2103
- // config.json 읽기 — createdAt 또는 mtime으로 나이 판정
2842
+ // 14. Stale Teams (Claude teams/ + tasks/ 자동 감지)
2843
+ section("Stale Teams");
2844
+ const teamsDir = join(CLAUDE_DIR, "teams");
2845
+ const _tasksDir = join(CLAUDE_DIR, "tasks");
2846
+ if (existsSync(teamsDir)) {
2847
+ try {
2848
+ const teamDirs = readdirSync(teamsDir).filter((d) => {
2104
2849
  try {
2105
- const configStat = statSync(configPath);
2106
- configMtimeMs = configStat.mtimeMs;
2107
- teamConfig = JSON.parse(readFileSync(configPath, "utf8"));
2850
+ return statSync(join(teamsDir, d)).isDirectory();
2108
2851
  } catch {
2109
- missingConfig = true;
2110
- // config.json 없으면 표시용 경과 시간만 디렉토리 기준으로 계산
2111
- try { configMtimeMs = statSync(teamPath).mtimeMs; } catch {}
2852
+ return false;
2112
2853
  }
2113
-
2114
- const createdAtMs = teamConfig?.createdAt ?? configMtimeMs;
2115
- const ageMs = createdAtMs != null ? Math.max(0, nowMs - createdAtMs) : null;
2116
- const ageSec = ageMs != null ? Math.floor(ageMs / 1000) : null;
2117
- const aged = ageMs != null && ageMs >= staleMaxAgeMs;
2118
-
2119
- // 활성 멤버 확인 — leadSessionId 또는 멤버 agentId로 프로세스 검색
2120
- let hasActiveMember = false;
2121
- if (teamConfig?.members?.length > 0) {
2122
- const searchTokens = [];
2123
- if (teamConfig.leadSessionId) searchTokens.push(teamConfig.leadSessionId.toLowerCase());
2124
- if (teamConfig.name) searchTokens.push(teamConfig.name.toLowerCase());
2125
- for (const member of teamConfig.members) {
2126
- if (member.agentId) searchTokens.push(member.agentId.split("@")[0].toLowerCase());
2854
+ });
2855
+ if (teamDirs.length === 0) {
2856
+ addDoctorCheck(report, {
2857
+ name: "stale-teams",
2858
+ status: "ok",
2859
+ entries: 0,
2860
+ });
2861
+ ok("잔존 없음");
2862
+ } else {
2863
+ const nowMs = Date.now();
2864
+ const staleMaxAgeMs = STALE_TEAM_MAX_AGE_SEC * 1000;
2865
+ const staleTeams = [];
2866
+ const activeTeams = [];
2867
+
2868
+ for (const d of teamDirs) {
2869
+ const teamPath = join(teamsDir, d);
2870
+ const configPath = join(teamPath, "config.json");
2871
+ let teamConfig = null;
2872
+ let configMtimeMs = null;
2873
+ let missingConfig = false;
2874
+
2875
+ // config.json 읽기 — createdAt 또는 mtime으로 나이 판정
2876
+ try {
2877
+ const configStat = statSync(configPath);
2878
+ configMtimeMs = configStat.mtimeMs;
2879
+ teamConfig = JSON.parse(readFileSync(configPath, "utf8"));
2880
+ } catch {
2881
+ missingConfig = true;
2882
+ // config.json 없으면 표시용 경과 시간만 디렉토리 기준으로 계산
2883
+ try {
2884
+ configMtimeMs = statSync(teamPath).mtimeMs;
2885
+ } catch {}
2127
2886
  }
2128
2887
 
2129
- // tmux 세션 이름과 매칭
2130
- const liveSessionNames = teamSessionReport.sessions.map(s => s.sessionName.toLowerCase());
2131
- hasActiveMember = searchTokens.some(token =>
2132
- liveSessionNames.some(name => name.includes(token))
2133
- );
2888
+ const createdAtMs = teamConfig?.createdAt ?? configMtimeMs;
2889
+ const ageMs =
2890
+ createdAtMs != null ? Math.max(0, nowMs - createdAtMs) : null;
2891
+ const ageSec = ageMs != null ? Math.floor(ageMs / 1000) : null;
2892
+ const aged = ageMs != null && ageMs >= staleMaxAgeMs;
2893
+
2894
+ // 활성 멤버 확인 — leadSessionId 또는 멤버 agentId로 프로세스 검색
2895
+ let hasActiveMember = false;
2896
+ if (teamConfig?.members?.length > 0) {
2897
+ const searchTokens = [];
2898
+ if (teamConfig.leadSessionId)
2899
+ searchTokens.push(teamConfig.leadSessionId.toLowerCase());
2900
+ if (teamConfig.name)
2901
+ searchTokens.push(teamConfig.name.toLowerCase());
2902
+ for (const member of teamConfig.members) {
2903
+ if (member.agentId)
2904
+ searchTokens.push(member.agentId.split("@")[0].toLowerCase());
2905
+ }
2134
2906
 
2135
- // 프로세스 명령줄에서 세션 ID 매칭 (tmux 없는 in-process 팀 지원)
2136
- if (!hasActiveMember && teamConfig.leadSessionId) {
2137
- try {
2138
- const _sessionToken = teamConfig.leadSessionId.toLowerCase();
2139
- const safeToken = teamConfig.leadSessionId.slice(0, 8).replace(/[^a-zA-Z0-9-]/g, '');
2140
- // Claude Code 프로세스에서 세션 ID 검색
2141
- if (process.platform === "win32") {
2142
- const psOut = execSync(
2143
- `powershell -NoProfile -WindowStyle Hidden -Command "$ErrorActionPreference='SilentlyContinue'; Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -match '${safeToken}' } | Select-Object ProcessId | ConvertTo-Json -Compress"`,
2144
- { encoding: "utf8", timeout: 8000, stdio: ["ignore", "pipe", "ignore"], windowsHide: true },
2145
- ).trim();
2146
- if (psOut && psOut !== "null") {
2147
- const parsed = JSON.parse(psOut);
2148
- const procs = Array.isArray(parsed) ? parsed : [parsed];
2149
- hasActiveMember = procs.some(p => p.ProcessId > 0);
2907
+ // tmux 세션 이름과 매칭
2908
+ const liveSessionNames = teamSessionReport.sessions.map((s) =>
2909
+ s.sessionName.toLowerCase(),
2910
+ );
2911
+ hasActiveMember = searchTokens.some((token) =>
2912
+ liveSessionNames.some((name) => name.includes(token)),
2913
+ );
2914
+
2915
+ // 프로세스 명령줄에서 세션 ID 매칭 (tmux 없는 in-process 지원)
2916
+ if (!hasActiveMember && teamConfig.leadSessionId) {
2917
+ try {
2918
+ const _sessionToken = teamConfig.leadSessionId.toLowerCase();
2919
+ const safeToken = teamConfig.leadSessionId
2920
+ .slice(0, 8)
2921
+ .replace(/[^a-zA-Z0-9-]/g, "");
2922
+ // Claude Code 프로세스에서 세션 ID 검색
2923
+ if (process.platform === "win32") {
2924
+ const psOut = execSync(
2925
+ `powershell -NoProfile -WindowStyle Hidden -Command "$ErrorActionPreference='SilentlyContinue'; Get-CimInstance Win32_Process | Where-Object { $_.CommandLine -match '${safeToken}' } | Select-Object ProcessId | ConvertTo-Json -Compress"`,
2926
+ {
2927
+ encoding: "utf8",
2928
+ timeout: 8000,
2929
+ stdio: ["ignore", "pipe", "ignore"],
2930
+ windowsHide: true,
2931
+ },
2932
+ ).trim();
2933
+ if (psOut && psOut !== "null") {
2934
+ const parsed = JSON.parse(psOut);
2935
+ const procs = Array.isArray(parsed) ? parsed : [parsed];
2936
+ hasActiveMember = procs.some((p) => p.ProcessId > 0);
2937
+ }
2938
+ } else {
2939
+ const psOut = execSync(
2940
+ `ps -ax -o pid=,command= | grep -i '${safeToken}' | grep -v grep`,
2941
+ {
2942
+ encoding: "utf8",
2943
+ timeout: 5000,
2944
+ stdio: ["ignore", "pipe", "ignore"],
2945
+ windowsHide: true,
2946
+ },
2947
+ ).trim();
2948
+ hasActiveMember = psOut.length > 0;
2150
2949
  }
2151
- } else {
2152
- const psOut = execSync(
2153
- `ps -ax -o pid=,command= | grep -i '${safeToken}' | grep -v grep`,
2154
- { encoding: "utf8", timeout: 5000, stdio: ["ignore", "pipe", "ignore"], windowsHide: true },
2155
- ).trim();
2156
- hasActiveMember = psOut.length > 0;
2950
+ } catch {
2951
+ // 프로세스 검색 실패 — stale로 간주하지 않음 (보수적)
2157
2952
  }
2158
- } catch {
2159
- // 프로세스 검색 실패 — stale로 간주하지 않음 (보수적)
2160
2953
  }
2161
2954
  }
2162
- }
2163
-
2164
- const stale = missingConfig || (aged && !hasActiveMember);
2165
- const teamEntry = {
2166
- name: d,
2167
- teamName: teamConfig?.name || d,
2168
- description: teamConfig?.description || null,
2169
- memberCount: teamConfig?.members?.length || 0,
2170
- ageSec,
2171
- stale,
2172
- hasActiveMember,
2173
- missingConfig,
2174
- };
2175
2955
 
2176
- if (stale) {
2177
- staleTeams.push(teamEntry);
2178
- } else {
2179
- activeTeams.push(teamEntry);
2956
+ const stale = missingConfig || (aged && !hasActiveMember);
2957
+ const teamEntry = {
2958
+ name: d,
2959
+ teamName: teamConfig?.name || d,
2960
+ description: teamConfig?.description || null,
2961
+ memberCount: teamConfig?.members?.length || 0,
2962
+ ageSec,
2963
+ stale,
2964
+ hasActiveMember,
2965
+ missingConfig,
2966
+ };
2967
+
2968
+ if (stale) {
2969
+ staleTeams.push(teamEntry);
2970
+ } else {
2971
+ activeTeams.push(teamEntry);
2972
+ }
2180
2973
  }
2181
- }
2182
2974
 
2183
- // 활성 팀 표시
2184
- for (const t of activeTeams) {
2185
- const ageLabel = formatElapsedAge(t.ageSec);
2186
- const memberLabel = `${t.memberCount}명`;
2187
- ok(`${t.name}: 활성 (경과=${ageLabel}, 멤버=${memberLabel})`);
2188
- }
2189
-
2190
- // stale 팀 표시 및 정리
2191
- if (staleTeams.length === 0 && activeTeams.length > 0) {
2192
- addDoctorCheck(report, { name: "stale-teams", status: "ok", active: activeTeams.length, stale: 0 });
2193
- ok("stale 팀 없음");
2194
- } else if (staleTeams.length > 0) {
2195
- addDoctorCheck(report, { name: "stale-teams", status: "issues", active: activeTeams.length, stale: staleTeams.length, fix: "tfx doctor --fix" });
2196
- warn(`${staleTeams.length}개 stale 팀 발견`);
2197
- for (const t of staleTeams) {
2975
+ // 활성 팀 표시
2976
+ for (const t of activeTeams) {
2198
2977
  const ageLabel = formatElapsedAge(t.ageSec);
2199
- const reasonLabel = t.missingConfig ? "config.json 없음" : "활성 프로세스 없음";
2200
- warn(`${t.name}: stale (경과=${ageLabel}, 멤버=${t.memberCount}명, ${reasonLabel})`);
2201
- if (t.description) info(`설명: ${t.description}`);
2978
+ const memberLabel = `${t.memberCount}명`;
2979
+ ok(`${t.name}: 활성 (경과=${ageLabel}, 멤버=${memberLabel})`);
2202
2980
  }
2203
2981
 
2204
- if (fix) {
2205
- let cleaned = 0;
2982
+ // stale 팀 표시 및 정리
2983
+ if (staleTeams.length === 0 && activeTeams.length > 0) {
2984
+ addDoctorCheck(report, {
2985
+ name: "stale-teams",
2986
+ status: "ok",
2987
+ active: activeTeams.length,
2988
+ stale: 0,
2989
+ });
2990
+ ok("stale 팀 없음");
2991
+ } else if (staleTeams.length > 0) {
2992
+ addDoctorCheck(report, {
2993
+ name: "stale-teams",
2994
+ status: "issues",
2995
+ active: activeTeams.length,
2996
+ stale: staleTeams.length,
2997
+ fix: "tfx doctor --fix",
2998
+ });
2999
+ warn(`${staleTeams.length}개 stale 팀 발견`);
2206
3000
  for (const t of staleTeams) {
2207
- try {
2208
- await forceCleanupTeam(t.name);
2209
- cleaned++;
2210
- ok(`stale 정리: ${t.name}`);
2211
- } catch (e) {
2212
- fail(`팀 정리 실패: ${t.name} ${e.message}`);
3001
+ const ageLabel = formatElapsedAge(t.ageSec);
3002
+ const reasonLabel = t.missingConfig
3003
+ ? "config.json 없음"
3004
+ : "활성 프로세스 없음";
3005
+ warn(
3006
+ `${t.name}: stale (경과=${ageLabel}, 멤버=${t.memberCount}명, ${reasonLabel})`,
3007
+ );
3008
+ if (t.description) info(`설명: ${t.description}`);
3009
+ }
3010
+
3011
+ if (fix) {
3012
+ let cleaned = 0;
3013
+ for (const t of staleTeams) {
3014
+ try {
3015
+ await forceCleanupTeam(t.name);
3016
+ cleaned++;
3017
+ ok(`stale 팀 정리: ${t.name}`);
3018
+ } catch (e) {
3019
+ fail(`팀 정리 실패: ${t.name} — ${e.message}`);
3020
+ }
2213
3021
  }
3022
+ info(`${cleaned}/${staleTeams.length}개 stale 팀 정리 완료`);
3023
+ } else {
3024
+ info("정리: tfx doctor --fix");
3025
+ issues += staleTeams.length;
2214
3026
  }
2215
- info(`${cleaned}/${staleTeams.length}개 stale 팀 정리 완료`);
2216
- } else {
2217
- info("정리: tfx doctor --fix");
2218
- issues += staleTeams.length;
2219
3027
  }
2220
3028
  }
3029
+ } catch (e) {
3030
+ addDoctorCheck(report, {
3031
+ name: "stale-teams",
3032
+ status: "invalid",
3033
+ fix: "teams 디렉토리 구조를 확인하세요.",
3034
+ });
3035
+ warn(`teams 디렉토리 읽기 실패: ${e.message}`);
2221
3036
  }
2222
- } catch (e) {
2223
- addDoctorCheck(report, { name: "stale-teams", status: "invalid", fix: "teams 디렉토리 구조를 확인하세요." });
2224
- warn(`teams 디렉토리 읽기 실패: ${e.message}`);
3037
+ } else {
3038
+ addDoctorCheck(report, { name: "stale-teams", status: "ok", entries: 0 });
3039
+ ok("잔존 없음");
2225
3040
  }
2226
- } else {
2227
- addDoctorCheck(report, { name: "stale-teams", status: "ok", entries: 0 });
2228
- ok("잔존 팀 없음");
2229
- }
2230
3041
 
2231
- // ── Docs 동기화 상태 ──
2232
- section("Docs Sync");
2233
- {
2234
- const docsDirs = ["docs/design", "docs/research"];
2235
- const missingDocs = [];
2236
- for (const dir of docsDirs) {
2237
- const src = join(PKG_ROOT, dir);
2238
- const dest = join(CLAUDE_DIR, dir);
2239
- if (existsSync(src)) {
2240
- const srcFiles = readdirSync(src).filter(f => f.endsWith(".md"));
2241
- if (!existsSync(dest)) {
2242
- missingDocs.push({ dir, missing: srcFiles.length, detail: "디렉토리 없음" });
2243
- } else {
2244
- const destFiles = readdirSync(dest).filter(f => f.endsWith(".md"));
2245
- const missing = srcFiles.filter(f => !destFiles.includes(f));
2246
- if (missing.length > 0) missingDocs.push({ dir, missing: missing.length, detail: missing.join(", ") });
3042
+ // ── Docs 동기화 상태 ──
3043
+ section("Docs Sync");
3044
+ {
3045
+ const docsDirs = ["docs/design", "docs/research"];
3046
+ const missingDocs = [];
3047
+ for (const dir of docsDirs) {
3048
+ const src = join(PKG_ROOT, dir);
3049
+ const dest = join(CLAUDE_DIR, dir);
3050
+ if (existsSync(src)) {
3051
+ const srcFiles = readdirSync(src).filter((f) => f.endsWith(".md"));
3052
+ if (!existsSync(dest)) {
3053
+ missingDocs.push({
3054
+ dir,
3055
+ missing: srcFiles.length,
3056
+ detail: "디렉토리 없음",
3057
+ });
3058
+ } else {
3059
+ const destFiles = readdirSync(dest).filter((f) =>
3060
+ f.endsWith(".md"),
3061
+ );
3062
+ const missing = srcFiles.filter((f) => !destFiles.includes(f));
3063
+ if (missing.length > 0)
3064
+ missingDocs.push({
3065
+ dir,
3066
+ missing: missing.length,
3067
+ detail: missing.join(", "),
3068
+ });
3069
+ }
2247
3070
  }
2248
3071
  }
2249
- }
2250
- if (missingDocs.length === 0) {
2251
- addDoctorCheck(report, { name: "docs-sync", status: "ok" });
2252
- ok("레퍼런스 문서 동기화 정상");
2253
- } else {
2254
- addDoctorCheck(report, { name: "docs-sync", status: "issues", missingDocs, fix: "tfx setup" });
2255
- warn(`${missingDocs.reduce((s, d) => s + d.missing, 0)}개 레퍼런스 미동기화`);
2256
- for (const d of missingDocs) info(`${d.dir}: ${d.detail}`);
2257
- if (fix) {
2258
- for (const dir of docsDirs) {
2259
- const src = join(PKG_ROOT, dir);
2260
- const dest = join(CLAUDE_DIR, dir);
2261
- if (existsSync(src)) {
2262
- mkdirSync(dest, { recursive: true });
2263
- for (const f of readdirSync(src).filter(f => f.endsWith(".md"))) {
2264
- copyFileSync(join(src, f), join(dest, f));
3072
+ if (missingDocs.length === 0) {
3073
+ addDoctorCheck(report, { name: "docs-sync", status: "ok" });
3074
+ ok("레퍼런스 문서 동기화 정상");
3075
+ } else {
3076
+ addDoctorCheck(report, {
3077
+ name: "docs-sync",
3078
+ status: "issues",
3079
+ missingDocs,
3080
+ fix: "tfx setup",
3081
+ });
3082
+ warn(
3083
+ `${missingDocs.reduce((s, d) => s + d.missing, 0)}개 레퍼런스 미동기화`,
3084
+ );
3085
+ for (const d of missingDocs) info(`${d.dir}: ${d.detail}`);
3086
+ if (fix) {
3087
+ for (const dir of docsDirs) {
3088
+ const src = join(PKG_ROOT, dir);
3089
+ const dest = join(CLAUDE_DIR, dir);
3090
+ if (existsSync(src)) {
3091
+ mkdirSync(dest, { recursive: true });
3092
+ for (const f of readdirSync(src).filter((f) =>
3093
+ f.endsWith(".md"),
3094
+ )) {
3095
+ copyFileSync(join(src, f), join(dest, f));
3096
+ }
2265
3097
  }
2266
3098
  }
3099
+ ok("레퍼런스 동기화 완료");
3100
+ } else {
3101
+ issues += missingDocs.length;
2267
3102
  }
2268
- ok("레퍼런스 동기화 완료");
2269
- } else {
2270
- issues += missingDocs.length;
2271
3103
  }
2272
3104
  }
2273
- }
2274
3105
 
2275
- // ── MCP 중앙 레지스트리 ──
2276
- section("MCP Registry");
2277
- {
2278
- let registryState = inspectRegistry();
2279
- if (!registryState.exists) {
2280
- saveRegistry(createDefaultRegistry());
2281
- registryState = inspectRegistry();
2282
- addDoctorCheck(report, {
2283
- name: "mcp-registry",
2284
- status: "fixed",
2285
- path: registryState.path,
2286
- action: "기본값으로 자동 생성됨",
2287
- });
2288
- ok("mcp-registry.json 없음 → 기본값으로 자동 생성됨");
2289
- } else if (!registryState.valid) {
2290
- saveRegistry(createDefaultRegistry());
2291
- registryState = inspectRegistry();
2292
- addDoctorCheck(report, {
2293
- name: "mcp-registry",
2294
- status: "fixed",
2295
- path: registryState.path,
2296
- action: "손상 감지 → 기본값으로 재생성됨",
2297
- });
2298
- warn("mcp-registry.json 손상 → 기본값으로 재생성됨");
2299
- } else {
2300
- const statusInfo = inspectRegistryStatus(registryState.registry);
2301
- const invalidConfigs = statusInfo.configs.filter((config) => config.parseError);
2302
- const mismatchRows = statusInfo.rows.filter((row) => row.type === "registry" && row.status === "mismatch");
2303
- const missingRows = statusInfo.rows.filter((row) => row.type === "registry" && row.status === "missing");
2304
- const missingFileRows = statusInfo.rows.filter((row) => row.type === "registry" && row.status === "missing-file");
2305
- const stdioRows = statusInfo.rows.filter((row) => row.type === "stdio");
2306
- const hasHardIssues = invalidConfigs.length > 0 || mismatchRows.length > 0;
2307
- const status = hasHardIssues
2308
- ? "issues"
2309
- : stdioRows.length > 0
2310
- ? "warning"
2311
- : "ok";
2312
-
2313
- addDoctorCheck(report, {
2314
- name: "mcp-registry",
2315
- status,
2316
- path: registryState.path,
2317
- server_count: Object.keys(registryState.registry.servers || {}).length,
2318
- rows: statusInfo.rows,
2319
- invalid_configs: invalidConfigs.map((config) => ({
2320
- file: config.filePath,
2321
- error: config.parseError?.message || "parse error",
2322
- })),
2323
- ...(stdioRows.length > 0 ? { fix: "tfx doctor --fix 또는 tfx mcp sync" } : {}),
2324
- });
3106
+ // ── MCP 중앙 레지스트리 ──
3107
+ section("MCP Registry");
3108
+ {
3109
+ let registryState = inspectRegistry();
3110
+ if (!registryState.exists) {
3111
+ saveRegistry(createDefaultRegistry());
3112
+ registryState = inspectRegistry();
3113
+ addDoctorCheck(report, {
3114
+ name: "mcp-registry",
3115
+ status: "fixed",
3116
+ path: registryState.path,
3117
+ action: "기본값으로 자동 생성됨",
3118
+ });
3119
+ ok("mcp-registry.json 없음 → 기본값으로 자동 생성됨");
3120
+ } else if (!registryState.valid) {
3121
+ saveRegistry(createDefaultRegistry());
3122
+ registryState = inspectRegistry();
3123
+ addDoctorCheck(report, {
3124
+ name: "mcp-registry",
3125
+ status: "fixed",
3126
+ path: registryState.path,
3127
+ action: "손상 감지 → 기본값으로 재생성됨",
3128
+ });
3129
+ warn("mcp-registry.json 손상 → 기본값으로 재생성됨");
3130
+ } else {
3131
+ const statusInfo = inspectRegistryStatus(registryState.registry);
3132
+ const invalidConfigs = statusInfo.configs.filter(
3133
+ (config) => config.parseError,
3134
+ );
3135
+ const mismatchRows = statusInfo.rows.filter(
3136
+ (row) => row.type === "registry" && row.status === "mismatch",
3137
+ );
3138
+ const missingRows = statusInfo.rows.filter(
3139
+ (row) => row.type === "registry" && row.status === "missing",
3140
+ );
3141
+ const missingFileRows = statusInfo.rows.filter(
3142
+ (row) => row.type === "registry" && row.status === "missing-file",
3143
+ );
3144
+ const stdioRows = statusInfo.rows.filter((row) => row.type === "stdio");
3145
+ const hasHardIssues =
3146
+ invalidConfigs.length > 0 || mismatchRows.length > 0;
3147
+ const status = hasHardIssues
3148
+ ? "issues"
3149
+ : stdioRows.length > 0
3150
+ ? "warning"
3151
+ : "ok";
2325
3152
 
2326
- ok(`registry 정상 (${Object.keys(registryState.registry.servers || {}).length}개 server)`);
3153
+ addDoctorCheck(report, {
3154
+ name: "mcp-registry",
3155
+ status,
3156
+ path: registryState.path,
3157
+ server_count: Object.keys(registryState.registry.servers || {})
3158
+ .length,
3159
+ rows: statusInfo.rows,
3160
+ invalid_configs: invalidConfigs.map((config) => ({
3161
+ file: config.filePath,
3162
+ error: config.parseError?.message || "parse error",
3163
+ })),
3164
+ ...(stdioRows.length > 0
3165
+ ? { fix: "tfx doctor --fix 또는 tfx mcp sync" }
3166
+ : {}),
3167
+ });
2327
3168
 
2328
- if (statusInfo.rows.length > 0) {
2329
- renderTable(
2330
- ["server", "target", "status", "config", "detail"],
2331
- buildMcpStatusRows(statusInfo),
3169
+ ok(
3170
+ `registry 정상 (${Object.keys(registryState.registry.servers || {}).length}개 server)`,
2332
3171
  );
2333
- } else {
2334
- info("등록된 MCP server 없음");
2335
- }
2336
3172
 
2337
- for (const config of invalidConfigs) {
2338
- fail(`${config.label}: 설정 파싱 실패`);
2339
- info(`${formatPathForDisplay(config.filePath)} ${config.parseError.message}`);
2340
- }
2341
-
2342
- for (const row of mismatchRows) {
2343
- warn(`${row.label}: ${row.name} URL 불일치`);
2344
- info(`expected ${row.expectedUrl}`);
2345
- if (row.actualUrl) info(`actual ${row.actualUrl}`);
2346
- }
3173
+ if (statusInfo.rows.length > 0) {
3174
+ renderTable(
3175
+ ["server", "target", "status", "config", "detail"],
3176
+ buildMcpStatusRows(statusInfo),
3177
+ );
3178
+ } else {
3179
+ info("등록된 MCP server 없음");
3180
+ }
2347
3181
 
2348
- for (const row of missingFileRows) {
2349
- info(`${row.label}: ${row.name} 미배치 (${formatPathForDisplay(row.filePath)})`);
2350
- }
3182
+ for (const config of invalidConfigs) {
3183
+ fail(`${config.label}: 설정 파싱 실패`);
3184
+ info(
3185
+ `${formatPathForDisplay(config.filePath)} — ${config.parseError.message}`,
3186
+ );
3187
+ }
2351
3188
 
2352
- for (const row of missingRows) {
2353
- info(`${row.label}: ${row.name} 누락`);
2354
- }
3189
+ for (const row of mismatchRows) {
3190
+ warn(`${row.label}: ${row.name} URL 불일치`);
3191
+ info(`expected ${row.expectedUrl}`);
3192
+ if (row.actualUrl) info(`actual ${row.actualUrl}`);
3193
+ }
2355
3194
 
2356
- if (stdioRows.length === 0) {
2357
- ok("미등록 stdio MCP 없음");
2358
- } else {
2359
- warn(`${stdioRows.length}개 미등록 stdio MCP 감지`);
2360
- for (const row of stdioRows) {
2361
- info(`${row.label}: ${row.name}${row.command ? ` (${row.command})` : ""}`);
3195
+ for (const row of missingFileRows) {
3196
+ info(
3197
+ `${row.label}: ${row.name} 미배치 (${formatPathForDisplay(row.filePath)})`,
3198
+ );
2362
3199
  }
2363
- }
2364
3200
 
2365
- issues += invalidConfigs.length;
2366
- issues += mismatchRows.length;
2367
- issues += stdioRows.length;
2368
- }
2369
- }
3201
+ for (const row of missingRows) {
3202
+ info(`${row.label}: ${row.name} 누락`);
3203
+ }
2370
3204
 
2371
- // ── Route Script 정합성 ──
2372
- section("Route Script Sync");
2373
- {
2374
- const srcRoute = join(PKG_ROOT, "scripts", "tfx-route.sh");
2375
- const destRoute = join(CLAUDE_DIR, "scripts", "tfx-route.sh");
2376
- if (existsSync(srcRoute) && existsSync(destRoute)) {
2377
- const srcHash = readFileSync(srcRoute, "utf8").length;
2378
- const destHash = readFileSync(destRoute, "utf8").length;
2379
- const srcContent = readFileSync(srcRoute, "utf8");
2380
- const destContent = readFileSync(destRoute, "utf8");
2381
- if (srcContent === destContent) {
2382
- addDoctorCheck(report, { name: "route-sync", status: "ok" });
2383
- ok("프로젝트 소스와 설치본 일치");
2384
- } else {
2385
- addDoctorCheck(report, { name: "route-sync", status: "issues", fix: "tfx setup" });
2386
- warn("tfx-route.sh 프로젝트 소스와 설치본 불일치");
2387
- info(`소스: ${srcRoute} (${srcHash}B) / 설치: ${destRoute} (${destHash}B)`);
2388
- if (fix) {
2389
- copyFileSync(srcRoute, destRoute);
2390
- ok("tfx-route.sh 동기화 완료");
3205
+ if (stdioRows.length === 0) {
3206
+ ok("미등록 stdio MCP 없음");
2391
3207
  } else {
2392
- issues++;
3208
+ warn(`${stdioRows.length}개 미등록 stdio MCP 감지`);
3209
+ for (const row of stdioRows) {
3210
+ info(
3211
+ `${row.label}: ${row.name}${row.command ? ` (${row.command})` : ""}`,
3212
+ );
3213
+ }
2393
3214
  }
3215
+
3216
+ issues += invalidConfigs.length;
3217
+ issues += mismatchRows.length;
3218
+ issues += stdioRows.length;
2394
3219
  }
2395
- } else if (existsSync(srcRoute) && !existsSync(destRoute)) {
2396
- addDoctorCheck(report, { name: "route-sync", status: "missing", fix: "tfx setup" });
2397
- fail("설치본 없음");
2398
- issues++;
2399
- } else {
2400
- addDoctorCheck(report, { name: "route-sync", status: "ok" });
2401
- ok("소스 없음 (npm 패키지 모드)");
2402
3220
  }
2403
- }
2404
-
2405
- // ── Hook Coverage (hook-registry vs settings.json) ──
2406
- section("Hook Coverage");
2407
- {
2408
- const registryPath = join(PKG_ROOT, "hooks", "hook-registry.json");
2409
- const settingsPath = join(CLAUDE_DIR, "settings.json");
2410
- const managedHooks = getManagedRegistryHooks(registryPath);
2411
3221
 
2412
- if (managedHooks.length === 0) {
2413
- addDoctorCheck(report, {
2414
- name: "hook-coverage",
2415
- status: "invalid",
2416
- total: 0,
2417
- registered: 0,
2418
- missing: [],
2419
- fix: "hook-registry.json을 확인하세요.",
2420
- });
2421
- warn("hook-registry.json에서 관리 대상 훅을 찾지 못했습니다.");
2422
- issues++;
2423
- } else {
2424
- let settings = {};
2425
- if (existsSync(settingsPath)) {
2426
- try {
2427
- settings = JSON.parse(readFileSync(settingsPath, "utf8"));
2428
- } catch (error) {
2429
- const unreadableCoverage = {
2430
- total: managedHooks.length,
2431
- registered: 0,
2432
- missing: managedHooks.map((spec) => toHookCoverageName(spec.fileName, spec.id)),
2433
- };
2434
- report.hook_coverage = unreadableCoverage;
3222
+ // ── Route Script 정합성 ──
3223
+ section("Route Script Sync");
3224
+ {
3225
+ const srcRoute = join(PKG_ROOT, "scripts", "tfx-route.sh");
3226
+ const destRoute = join(CLAUDE_DIR, "scripts", "tfx-route.sh");
3227
+ if (existsSync(srcRoute) && existsSync(destRoute)) {
3228
+ const srcHash = readFileSync(srcRoute, "utf8").length;
3229
+ const destHash = readFileSync(destRoute, "utf8").length;
3230
+ const srcContent = readFileSync(srcRoute, "utf8");
3231
+ const destContent = readFileSync(destRoute, "utf8");
3232
+ if (srcContent === destContent) {
3233
+ addDoctorCheck(report, { name: "route-sync", status: "ok" });
3234
+ ok("프로젝트 소스와 설치본 일치");
3235
+ } else {
2435
3236
  addDoctorCheck(report, {
2436
- name: "hook-coverage",
2437
- status: "invalid",
2438
- total: unreadableCoverage.total,
2439
- registered: unreadableCoverage.registered,
2440
- missing: unreadableCoverage.missing,
2441
- fix: "settings.json 문법을 수정하거나 tfx setup을 다시 실행하세요.",
3237
+ name: "route-sync",
3238
+ status: "issues",
3239
+ fix: "tfx setup",
2442
3240
  });
2443
- fail(`settings.json 파싱 실패: ${error.message}`);
2444
- issues++;
2445
- settings = null;
3241
+ warn("tfx-route.sh 프로젝트 소스와 설치본 불일치");
3242
+ info(
3243
+ `소스: ${srcRoute} (${srcHash}B) / 설치: ${destRoute} (${destHash}B)`,
3244
+ );
3245
+ if (fix) {
3246
+ copyFileSync(srcRoute, destRoute);
3247
+ ok("tfx-route.sh 동기화 완료");
3248
+ } else {
3249
+ issues++;
3250
+ }
2446
3251
  }
3252
+ } else if (existsSync(srcRoute) && !existsSync(destRoute)) {
3253
+ addDoctorCheck(report, {
3254
+ name: "route-sync",
3255
+ status: "missing",
3256
+ fix: "tfx setup",
3257
+ });
3258
+ fail("설치본 없음");
3259
+ issues++;
3260
+ } else {
3261
+ addDoctorCheck(report, { name: "route-sync", status: "ok" });
3262
+ ok("소스 없음 (npm 패키지 모드)");
2447
3263
  }
3264
+ }
2448
3265
 
2449
- if (settings) {
2450
- let coverage = computeHookCoverage(settings, managedHooks);
3266
+ // ── Hook Coverage (hook-registry vs settings.json) ──
3267
+ section("Hook Coverage");
3268
+ {
3269
+ const registryPath = join(PKG_ROOT, "hooks", "hook-registry.json");
3270
+ const settingsPath = join(CLAUDE_DIR, "settings.json");
3271
+ const managedHooks = getManagedRegistryHooks(registryPath);
2451
3272
 
2452
- if (coverage.missing.length > 0 && fix) {
2453
- const hookFixResult = ensureHooksInSettings({ settingsPath, registryPath });
2454
- if (hookFixResult.ok) {
2455
- if (hookFixResult.changed) {
2456
- ok(`누락 훅 ${hookFixResult.added.length}개 자동 등록됨`);
2457
- } else {
2458
- info("누락 훅 자동 등록: 변경 사항 없음");
2459
- }
2460
- try {
2461
- const fixedSettings = JSON.parse(readFileSync(settingsPath, "utf8"));
2462
- coverage = computeHookCoverage(fixedSettings, managedHooks);
2463
- } catch (error) {
2464
- warn(`자동 등록 후 settings.json 재검증 실패: ${error.message}`);
2465
- }
2466
- } else {
2467
- warn(`누락 자동 등록 실패: ${hookFixResult.reason || "unknown_error"}`);
3273
+ if (managedHooks.length === 0) {
3274
+ addDoctorCheck(report, {
3275
+ name: "hook-coverage",
3276
+ status: "invalid",
3277
+ total: 0,
3278
+ registered: 0,
3279
+ missing: [],
3280
+ fix: "hook-registry.json을 확인하세요.",
3281
+ });
3282
+ warn("hook-registry.json에서 관리 대상 훅을 찾지 못했습니다.");
3283
+ issues++;
3284
+ } else {
3285
+ let settings = {};
3286
+ if (existsSync(settingsPath)) {
3287
+ try {
3288
+ settings = JSON.parse(readFileSync(settingsPath, "utf8"));
3289
+ } catch (error) {
3290
+ const unreadableCoverage = {
3291
+ total: managedHooks.length,
3292
+ registered: 0,
3293
+ missing: managedHooks.map((spec) =>
3294
+ toHookCoverageName(spec.fileName, spec.id),
3295
+ ),
3296
+ };
3297
+ report.hook_coverage = unreadableCoverage;
3298
+ addDoctorCheck(report, {
3299
+ name: "hook-coverage",
3300
+ status: "invalid",
3301
+ total: unreadableCoverage.total,
3302
+ registered: unreadableCoverage.registered,
3303
+ missing: unreadableCoverage.missing,
3304
+ fix: "settings.json 문법을 수정하거나 tfx setup을 다시 실행하세요.",
3305
+ });
3306
+ fail(`settings.json 파싱 실패: ${error.message}`);
3307
+ issues++;
3308
+ settings = null;
2468
3309
  }
2469
3310
  }
2470
3311
 
2471
- // 중복 훅 감지 + 자동 수정 (orchestrator와 개별 훅이 동시 등록된 경우)
2472
- if (coverage.duplicates && coverage.duplicates.length > 0) {
2473
- if (fix) {
2474
- try {
2475
- const fixedSettings = JSON.parse(readFileSync(settingsPath, "utf8"));
2476
- let removed = 0;
2477
- for (const [event, entries] of Object.entries(fixedSettings.hooks || {})) {
2478
- if (!Array.isArray(entries)) continue;
2479
- const hasOrch = entries.some((e) =>
2480
- Array.isArray(e?.hooks) &&
2481
- e.hooks.some((h) => typeof h?.command === "string" && h.command.includes("hook-orchestrator")),
3312
+ if (settings) {
3313
+ let coverage = computeHookCoverage(settings, managedHooks);
3314
+
3315
+ if (coverage.missing.length > 0 && fix) {
3316
+ const hookFixResult = ensureHooksInSettings({
3317
+ settingsPath,
3318
+ registryPath,
3319
+ });
3320
+ if (hookFixResult.ok) {
3321
+ if (hookFixResult.changed) {
3322
+ ok(`누락 ${hookFixResult.added.length}개 자동 등록됨`);
3323
+ } else {
3324
+ info("누락 훅 자동 등록: 변경 사항 없음");
3325
+ }
3326
+ try {
3327
+ const fixedSettings = JSON.parse(
3328
+ readFileSync(settingsPath, "utf8"),
2482
3329
  );
2483
- if (!hasOrch) continue;
2484
- // 패턴 A: orchestrator 없는 별도 엔트리 제거
2485
- const before = entries.length;
2486
- fixedSettings.hooks[event] = entries.filter((e) =>
2487
- Array.isArray(e?.hooks) &&
2488
- e.hooks.some((h) => typeof h?.command === "string" && h.command.includes("hook-orchestrator")),
3330
+ coverage = computeHookCoverage(fixedSettings, managedHooks);
3331
+ } catch (error) {
3332
+ warn(
3333
+ `자동 등록 후 settings.json 재검증 실패: ${error.message}`,
2489
3334
  );
2490
- removed += before - fixedSettings.hooks[event].length;
2491
- // 패턴 B: orchestrator 엔트리 내부의 개별 훅 제거
2492
- for (const entry of fixedSettings.hooks[event]) {
2493
- if (!Array.isArray(entry.hooks) || entry.hooks.length <= 1) continue;
2494
- const beforeInner = entry.hooks.length;
2495
- entry.hooks = entry.hooks.filter(
2496
- (h) => typeof h?.command === "string" && h.command.includes("hook-orchestrator"),
3335
+ }
3336
+ } else {
3337
+ warn(
3338
+ `누락 자동 등록 실패: ${hookFixResult.reason || "unknown_error"}`,
3339
+ );
3340
+ }
3341
+ }
3342
+
3343
+ // 중복 훅 감지 + 자동 수정 (orchestrator와 개별 훅이 동시 등록된 경우)
3344
+ if (coverage.duplicates && coverage.duplicates.length > 0) {
3345
+ if (fix) {
3346
+ try {
3347
+ const fixedSettings = JSON.parse(
3348
+ readFileSync(settingsPath, "utf8"),
3349
+ );
3350
+ let removed = 0;
3351
+ for (const [event, entries] of Object.entries(
3352
+ fixedSettings.hooks || {},
3353
+ )) {
3354
+ if (!Array.isArray(entries)) continue;
3355
+ const hasOrch = entries.some(
3356
+ (e) =>
3357
+ Array.isArray(e?.hooks) &&
3358
+ e.hooks.some(
3359
+ (h) =>
3360
+ typeof h?.command === "string" &&
3361
+ h.command.includes("hook-orchestrator"),
3362
+ ),
2497
3363
  );
2498
- removed += beforeInner - entry.hooks.length;
3364
+ if (!hasOrch) continue;
3365
+ // 패턴 A: orchestrator 없는 별도 엔트리 제거
3366
+ const before = entries.length;
3367
+ fixedSettings.hooks[event] = entries.filter(
3368
+ (e) =>
3369
+ Array.isArray(e?.hooks) &&
3370
+ e.hooks.some(
3371
+ (h) =>
3372
+ typeof h?.command === "string" &&
3373
+ h.command.includes("hook-orchestrator"),
3374
+ ),
3375
+ );
3376
+ removed += before - fixedSettings.hooks[event].length;
3377
+ // 패턴 B: orchestrator 엔트리 내부의 개별 훅 제거
3378
+ for (const entry of fixedSettings.hooks[event]) {
3379
+ if (!Array.isArray(entry.hooks) || entry.hooks.length <= 1)
3380
+ continue;
3381
+ const beforeInner = entry.hooks.length;
3382
+ entry.hooks = entry.hooks.filter(
3383
+ (h) =>
3384
+ typeof h?.command === "string" &&
3385
+ h.command.includes("hook-orchestrator"),
3386
+ );
3387
+ removed += beforeInner - entry.hooks.length;
3388
+ }
2499
3389
  }
3390
+ if (removed > 0) {
3391
+ writeFileSync(
3392
+ settingsPath,
3393
+ JSON.stringify(fixedSettings, null, 2) + "\n",
3394
+ "utf8",
3395
+ );
3396
+ ok(
3397
+ `중복 훅 ${removed}개 엔트리 제거됨 (orchestrator가 체이닝)`,
3398
+ );
3399
+ const rechecked = JSON.parse(
3400
+ readFileSync(settingsPath, "utf8"),
3401
+ );
3402
+ coverage = computeHookCoverage(rechecked, managedHooks);
3403
+ }
3404
+ } catch (error) {
3405
+ warn(`중복 훅 자동 제거 실패: ${error.message}`);
2500
3406
  }
2501
- if (removed > 0) {
2502
- writeFileSync(settingsPath, JSON.stringify(fixedSettings, null, 2) + "\n", "utf8");
2503
- ok(`중복 훅 ${removed}개 엔트리 제거됨 (orchestrator가 체이닝)`);
2504
- const rechecked = JSON.parse(readFileSync(settingsPath, "utf8"));
2505
- coverage = computeHookCoverage(rechecked, managedHooks);
2506
- }
2507
- } catch (error) {
2508
- warn(`중복 훅 자동 제거 실패: ${error.message}`);
3407
+ } else {
3408
+ warn(
3409
+ `중복 훅 ${coverage.duplicates.length}개 감지 (이중 실행됨): ${coverage.duplicates.join(", ")}`,
3410
+ );
3411
+ warn("tfx doctor --fix 로 자동 제거하세요.");
3412
+ issues += coverage.duplicates.length;
2509
3413
  }
2510
- } else {
2511
- warn(`중복 훅 ${coverage.duplicates.length}개 감지 (이중 실행됨): ${coverage.duplicates.join(", ")}`);
2512
- warn("tfx doctor --fix 로 자동 제거하세요.");
2513
- issues += coverage.duplicates.length;
2514
3414
  }
2515
- }
2516
3415
 
2517
- report.hook_coverage = coverage;
2518
- const coverageStatus = coverage.missing.length === 0 && (!coverage.duplicates || coverage.duplicates.length === 0) ? "ok" : "issues";
2519
- addDoctorCheck(report, {
2520
- name: "hook-coverage",
2521
- status: coverageStatus,
2522
- total: coverage.total,
2523
- registered: coverage.registered,
2524
- missing: coverage.missing,
2525
- duplicates: coverage.duplicates || [],
2526
- ...(coverage.missing.length > 0 ? { fix: "tfx doctor --fix 또는 tfx setup" } : {}),
2527
- ...(coverage.duplicates?.length > 0 ? { fix: "tfx doctor --fix 로 중복 훅 제거" } : {}),
2528
- });
3416
+ report.hook_coverage = coverage;
3417
+ const coverageStatus =
3418
+ coverage.missing.length === 0 &&
3419
+ (!coverage.duplicates || coverage.duplicates.length === 0)
3420
+ ? "ok"
3421
+ : "issues";
3422
+ addDoctorCheck(report, {
3423
+ name: "hook-coverage",
3424
+ status: coverageStatus,
3425
+ total: coverage.total,
3426
+ registered: coverage.registered,
3427
+ missing: coverage.missing,
3428
+ duplicates: coverage.duplicates || [],
3429
+ ...(coverage.missing.length > 0
3430
+ ? { fix: "tfx doctor --fix 또는 tfx setup" }
3431
+ : {}),
3432
+ ...(coverage.duplicates?.length > 0
3433
+ ? { fix: "tfx doctor --fix 로 중복 훅 제거" }
3434
+ : {}),
3435
+ });
2529
3436
 
2530
- if (coverage.missing.length === 0 && (!coverage.duplicates || coverage.duplicates.length === 0)) {
2531
- ok(`Hook Coverage: ${coverage.registered}/${coverage.total} registered`);
2532
- } else if (coverage.missing.length > 0) {
2533
- fail(`Missing hooks: ${coverage.missing.join(", ")}`);
2534
- issues += coverage.missing.length;
3437
+ if (
3438
+ coverage.missing.length === 0 &&
3439
+ (!coverage.duplicates || coverage.duplicates.length === 0)
3440
+ ) {
3441
+ ok(
3442
+ `Hook Coverage: ${coverage.registered}/${coverage.total} registered`,
3443
+ );
3444
+ } else if (coverage.missing.length > 0) {
3445
+ fail(`Missing hooks: ${coverage.missing.join(", ")}`);
3446
+ issues += coverage.missing.length;
3447
+ }
2535
3448
  }
2536
3449
  }
2537
3450
  }
2538
- }
2539
3451
 
2540
- // 결과
2541
- console.log(`\n ${LINE}`);
2542
- if (issues === 0) {
2543
- console.log(` ${GREEN_BRIGHT}${BOLD}✓ 모든 검사 통과${RESET}\n`);
2544
- } else {
2545
- console.log(` ${YELLOW}${BOLD}⚠ ${issues}개 항목 확인 필요${RESET}\n`);
2546
- }
3452
+ // 결과
3453
+ console.log(`\n ${LINE}`);
3454
+ if (issues === 0) {
3455
+ console.log(` ${GREEN_BRIGHT}${BOLD}✓ 모든 검사 통과${RESET}\n`);
3456
+ } else {
3457
+ console.log(` ${YELLOW}${BOLD}⚠ ${issues}개 항목 확인 필요${RESET}\n`);
3458
+ }
2547
3459
  report.issue_count = issues;
2548
3460
  report.status = issues === 0 ? "ok" : "issues";
2549
3461
  if (json) printJson(report);
@@ -2588,7 +3500,7 @@ function resolveGitUpdateUrl(repoDir) {
2588
3500
  }
2589
3501
 
2590
3502
  function resolveUpdateTargets({ installMode, pluginPath }) {
2591
- const repoDir = installMode === "plugin" ? (pluginPath || PKG_ROOT) : PKG_ROOT;
3503
+ const repoDir = installMode === "plugin" ? pluginPath || PKG_ROOT : PKG_ROOT;
2592
3504
  const gitUrl = resolveGitUpdateUrl(repoDir);
2593
3505
 
2594
3506
  if (installMode === "npm-global" || installMode === "npm-local") {
@@ -2625,7 +3537,10 @@ async function cmdUpdate() {
2625
3537
  }
2626
3538
 
2627
3539
  // PKG_ROOT가 플러그인 캐시 내에 있으면 플러그인 모드
2628
- if (installMode === "unknown" && PKG_ROOT.includes(join(".claude", "plugins"))) {
3540
+ if (
3541
+ installMode === "unknown" &&
3542
+ PKG_ROOT.includes(join(".claude", "plugins"))
3543
+ ) {
2629
3544
  installMode = "plugin";
2630
3545
  pluginPath = PKG_ROOT;
2631
3546
  }
@@ -2654,7 +3569,9 @@ async function cmdUpdate() {
2654
3569
  installMode = "git-local";
2655
3570
  }
2656
3571
 
2657
- info(`검색: ${installMode === "plugin" ? "플러그인" : installMode === "npm-global" ? "npm global" : installMode === "npm-local" ? "npm local" : installMode === "git-local" ? "git 로컬 저장소" : "알 수 없음"} 설치 감지`);
3572
+ info(
3573
+ `검색: ${installMode === "plugin" ? "플러그인" : installMode === "npm-global" ? "npm global" : installMode === "npm-local" ? "npm local" : installMode === "git-local" ? "git 로컬 저장소" : "알 수 없음"} 설치 감지`,
3574
+ );
2658
3575
 
2659
3576
  const networkTargets = resolveUpdateTargets({ installMode, pluginPath });
2660
3577
  if (networkTargets.length > 0) {
@@ -2701,7 +3618,9 @@ async function cmdUpdate() {
2701
3618
  if (stoppedHubInfo?.pid) {
2702
3619
  info(`실행 중 hub 정지 (PID ${stoppedHubInfo.pid})`);
2703
3620
  }
2704
- const npmCmd = isDev ? "npm install -g triflux@dev" : "npm install -g triflux@latest";
3621
+ const npmCmd = isDev
3622
+ ? "npm install -g triflux@dev"
3623
+ : "npm install -g triflux@latest";
2705
3624
  let result;
2706
3625
  try {
2707
3626
  result = execSync(npmCmd, {
@@ -2709,7 +3628,9 @@ async function cmdUpdate() {
2709
3628
  timeout: 90000,
2710
3629
  stdio: ["pipe", "pipe", "pipe"],
2711
3630
  windowsHide: true,
2712
- }).trim().split(/\r?\n/)[0];
3631
+ })
3632
+ .trim()
3633
+ .split(/\r?\n/)[0];
2713
3634
  } catch {
2714
3635
  // Windows: 자기 자신의 파일 잠금으로 첫 시도 실패 가능 → --force 재시도
2715
3636
  info("첫 시도 실패, --force 재시도 중...");
@@ -2718,22 +3639,30 @@ async function cmdUpdate() {
2718
3639
  timeout: 90000,
2719
3640
  stdio: ["pipe", "pipe", "pipe"],
2720
3641
  windowsHide: true,
2721
- }).trim().split(/\r?\n/)[0];
3642
+ })
3643
+ .trim()
3644
+ .split(/\r?\n/)[0];
2722
3645
  }
2723
3646
  ok(`${npmCmd} — ${result || "완료"}`);
2724
3647
  updated = true;
2725
3648
  break;
2726
3649
  }
2727
3650
  case "npm-local": {
2728
- const npmLocalCmd = isDev ? "npm install triflux@dev" : "npm update triflux";
3651
+ const npmLocalCmd = isDev
3652
+ ? "npm install triflux@dev"
3653
+ : "npm update triflux";
2729
3654
  const result = execSync(npmLocalCmd, {
2730
3655
  encoding: "utf8",
2731
3656
  timeout: 60000,
2732
3657
  cwd: process.cwd(),
2733
3658
  stdio: ["pipe", "pipe", "ignore"],
2734
3659
  windowsHide: true,
2735
- }).trim().split(/\r?\n/)[0];
2736
- ok(`${isDev ? "npm install triflux@dev" : "npm update triflux"} — ${result || "완료"}`);
3660
+ })
3661
+ .trim()
3662
+ .split(/\r?\n/)[0];
3663
+ ok(
3664
+ `${isDev ? "npm install triflux@dev" : "npm update triflux"} — ${result || "완료"}`,
3665
+ );
2737
3666
  updated = true;
2738
3667
  break;
2739
3668
  }
@@ -2758,7 +3687,9 @@ async function cmdUpdate() {
2758
3687
  info("업데이트 실패 후 hub 재기동 시도");
2759
3688
  }
2760
3689
  const stderr = e.stderr?.toString().trim();
2761
- fail(`업데이트 실패: ${e.message}${stderr ? `\n ${stderr.split(/\r?\n/)[0]}` : ""}`);
3690
+ fail(
3691
+ `업데이트 실패: ${e.message}${stderr ? `\n ${stderr.split(/\r?\n/)[0]}` : ""}`,
3692
+ );
2762
3693
  return;
2763
3694
  }
2764
3695
 
@@ -2768,7 +3699,9 @@ async function cmdUpdate() {
2768
3699
  // 업데이트 후 새 버전 읽기
2769
3700
  let newVer = oldVer;
2770
3701
  try {
2771
- const newPkg = JSON.parse(readFileSync(join(PKG_ROOT, "package.json"), "utf8"));
3702
+ const newPkg = JSON.parse(
3703
+ readFileSync(join(PKG_ROOT, "package.json"), "utf8"),
3704
+ );
2772
3705
  newVer = newPkg.version;
2773
3706
  } catch {}
2774
3707
 
@@ -2785,22 +3718,37 @@ async function cmdUpdate() {
2785
3718
  // stale 캐시 삭제
2786
3719
  for (const name of ["tfx-preflight.json", "mcp-inventory.json"]) {
2787
3720
  const p = join(cacheDir, name);
2788
- if (existsSync(p)) { try { unlinkSync(p); } catch {} }
3721
+ if (existsSync(p)) {
3722
+ try {
3723
+ unlinkSync(p);
3724
+ } catch {}
3725
+ }
2789
3726
  }
2790
3727
  // tmpdir 상태 파일 정리
2791
3728
  for (const name of ["tfx-multi-state.json"]) {
2792
3729
  const p = join(tmpdir(), name);
2793
- if (existsSync(p)) { try { unlinkSync(p); } catch {} }
3730
+ if (existsSync(p)) {
3731
+ try {
3732
+ unlinkSync(p);
3733
+ } catch {}
3734
+ }
2794
3735
  }
2795
3736
 
2796
3737
  // preflight 캐시 재생성
2797
3738
  const preflightScript = join(PKG_ROOT, "scripts", "preflight-cache.mjs");
2798
3739
  if (existsSync(preflightScript)) {
2799
3740
  try {
2800
- execSync(`node "${preflightScript}"`, { encoding: "utf8", timeout: 15000, windowsHide: true, stdio: "pipe" });
3741
+ execSync(`node "${preflightScript}"`, {
3742
+ encoding: "utf8",
3743
+ timeout: 15000,
3744
+ windowsHide: true,
3745
+ stdio: "pipe",
3746
+ });
2801
3747
  ok("preflight 캐시 재생성 완료");
2802
3748
  } catch (e) {
2803
- warn(`preflight 캐시 재생성 실패: ${e.message?.split(/\r?\n/)[0] || "unknown"}`);
3749
+ warn(
3750
+ `preflight 캐시 재생성 실패: ${e.message?.split(/\r?\n/)[0] || "unknown"}`,
3751
+ );
2804
3752
  }
2805
3753
  }
2806
3754
 
@@ -2808,10 +3756,17 @@ async function cmdUpdate() {
2808
3756
  const mcpCheckScript = join(PKG_ROOT, "scripts", "mcp-check.mjs");
2809
3757
  if (existsSync(mcpCheckScript)) {
2810
3758
  try {
2811
- execSync(`node "${mcpCheckScript}"`, { encoding: "utf8", timeout: 10000, windowsHide: true, stdio: "pipe" });
3759
+ execSync(`node "${mcpCheckScript}"`, {
3760
+ encoding: "utf8",
3761
+ timeout: 10000,
3762
+ windowsHide: true,
3763
+ stdio: "pipe",
3764
+ });
2812
3765
  ok("MCP 인벤토리 캐시 재생성 완료");
2813
3766
  } catch (e) {
2814
- warn(`MCP 인벤토리 재생성 실패: ${e.message?.split(/\r?\n/)[0] || "unknown"}`);
3767
+ warn(
3768
+ `MCP 인벤토리 재생성 실패: ${e.message?.split(/\r?\n/)[0] || "unknown"}`,
3769
+ );
2815
3770
  }
2816
3771
  }
2817
3772
  }
@@ -2820,10 +3775,22 @@ async function cmdUpdate() {
2820
3775
  console.log(`\n${CYAN}── 무결성 검증 ──${RESET}`);
2821
3776
  {
2822
3777
  const criticalFiles = [
2823
- { path: join(PKG_ROOT, "hooks", "hook-orchestrator.mjs"), label: "hook-orchestrator" },
2824
- { path: join(PKG_ROOT, "hooks", "hook-registry.json"), label: "hook-registry" },
2825
- { path: join(PKG_ROOT, "hooks", "safety-guard.mjs"), label: "safety-guard" },
2826
- { path: join(PKG_ROOT, "scripts", "keyword-detector.mjs"), label: "keyword-detector" },
3778
+ {
3779
+ path: join(PKG_ROOT, "hooks", "hook-orchestrator.mjs"),
3780
+ label: "hook-orchestrator",
3781
+ },
3782
+ {
3783
+ path: join(PKG_ROOT, "hooks", "hook-registry.json"),
3784
+ label: "hook-registry",
3785
+ },
3786
+ {
3787
+ path: join(PKG_ROOT, "hooks", "safety-guard.mjs"),
3788
+ label: "safety-guard",
3789
+ },
3790
+ {
3791
+ path: join(PKG_ROOT, "scripts", "keyword-detector.mjs"),
3792
+ label: "keyword-detector",
3793
+ },
2827
3794
  { path: join(PKG_ROOT, "scripts", "setup.mjs"), label: "setup" },
2828
3795
  { path: join(PKG_ROOT, "bin", "triflux.mjs"), label: "triflux CLI" },
2829
3796
  ];
@@ -2835,7 +3802,9 @@ async function cmdUpdate() {
2835
3802
  }
2836
3803
  }
2837
3804
  if (missing > 0) {
2838
- fail(`핵심 파일 ${missing}개 누락 — npm install -g triflux@latest 재설치 필요`);
3805
+ fail(
3806
+ `핵심 파일 ${missing}개 누락 — npm install -g triflux@latest 재설치 필요`,
3807
+ );
2839
3808
  } else {
2840
3809
  ok(`핵심 파일 ${criticalFiles.length}개 확인 완료`);
2841
3810
  }
@@ -2845,7 +3814,8 @@ async function cmdUpdate() {
2845
3814
  console.log(`\n${CYAN}── CLAUDE.md 라우팅 동기화 ──${RESET}`);
2846
3815
  {
2847
3816
  const claudeRoutingResults = syncClaudeRoutingSectionsForCli();
2848
- const claudeRoutingSummary = getClaudeRoutingSyncSummary(claudeRoutingResults);
3817
+ const claudeRoutingSummary =
3818
+ getClaudeRoutingSyncSummary(claudeRoutingResults);
2849
3819
  if (claudeRoutingSummary.changed > 0) {
2850
3820
  ok(`CLAUDE.md 라우팅 ${claudeRoutingSummary.changed}개 파일 반영`);
2851
3821
  } else if (claudeRoutingSummary.skipped > 0) {
@@ -2857,7 +3827,11 @@ async function cmdUpdate() {
2857
3827
 
2858
3828
  // ── Post-update: 설정 동기화 ──
2859
3829
  console.log(`\n${CYAN}── 설정 동기화 ──${RESET}`);
2860
- cmdSetup({ fromUpdate: true, overrideVersion: newVer, skipClaudeMdSync: true });
3830
+ cmdSetup({
3831
+ fromUpdate: true,
3832
+ overrideVersion: newVer,
3833
+ skipClaudeMdSync: true,
3834
+ });
2861
3835
 
2862
3836
  // ── Post-update: 훅 오케스트레이터 적용 ──
2863
3837
  {
@@ -2871,10 +3845,14 @@ async function cmdUpdate() {
2871
3845
  }).trim();
2872
3846
  const parsed = JSON.parse(result);
2873
3847
  if (parsed?.status === "applied") {
2874
- ok(`훅 오케스트레이터 적용 (${parsed.events?.length || 0}개 이벤트)`);
3848
+ ok(
3849
+ `훅 오케스트레이터 적용 (${parsed.events?.length || 0}개 이벤트)`,
3850
+ );
2875
3851
  }
2876
3852
  } catch (e) {
2877
- warn(`훅 오케스트레이터 적용 실패: ${e.message?.split(/\r?\n/)[0] || "unknown"}`);
3853
+ warn(
3854
+ `훅 오케스트레이터 적용 실패: ${e.message?.split(/\r?\n/)[0] || "unknown"}`,
3855
+ );
2878
3856
  warn("tfx hooks apply 로 수동 적용하세요.");
2879
3857
  }
2880
3858
  } else {
@@ -2914,7 +3892,9 @@ function cmdList(options = {}) {
2914
3892
  skillAliases.push({ alias, source, installed: existsSync(dst) });
2915
3893
  }
2916
3894
 
2917
- const pkgNames = new Set(existsSync(pluginSkills) ? readdirSync(pluginSkills) : []);
3895
+ const pkgNames = new Set(
3896
+ existsSync(pluginSkills) ? readdirSync(pluginSkills) : [],
3897
+ );
2918
3898
  if (existsSync(installedSkills)) {
2919
3899
  for (const name of readdirSync(installedSkills).sort()) {
2920
3900
  if (pkgNames.has(name) || aliasNames.has(name)) continue;
@@ -2942,7 +3922,9 @@ function cmdList(options = {}) {
2942
3922
  if (skill.installed) {
2943
3923
  console.log(` ${GREEN_BRIGHT}✓${RESET} ${BOLD}${skill.name}${RESET}`);
2944
3924
  } else {
2945
- console.log(` ${RED_BRIGHT}✗${RESET} ${DIM}${skill.name}${RESET} ${GRAY}(미설치)${RESET}`);
3925
+ console.log(
3926
+ ` ${RED_BRIGHT}✗${RESET} ${DIM}${skill.name}${RESET} ${GRAY}(미설치)${RESET}`,
3927
+ );
2946
3928
  }
2947
3929
  }
2948
3930
 
@@ -2955,9 +3937,13 @@ function cmdList(options = {}) {
2955
3937
  if (skillAliases.length > 0) {
2956
3938
  section("호환 alias");
2957
3939
  for (const entry of skillAliases) {
2958
- const icon = entry.installed ? `${GREEN_BRIGHT}↳${RESET}` : `${RED_BRIGHT}↳${RESET}`;
3940
+ const icon = entry.installed
3941
+ ? `${GREEN_BRIGHT}↳${RESET}`
3942
+ : `${RED_BRIGHT}↳${RESET}`;
2959
3943
  const status = entry.installed ? "" : ` ${GRAY}(미설치)${RESET}`;
2960
- console.log(` ${icon} ${BOLD}${entry.alias}${RESET} ${GRAY}→ ${entry.source}${RESET}${status}`);
3944
+ console.log(
3945
+ ` ${icon} ${BOLD}${entry.alias}${RESET} ${GRAY}→ ${entry.source}${RESET}${status}`,
3946
+ );
2961
3947
  }
2962
3948
  }
2963
3949
 
@@ -2978,7 +3964,9 @@ function cmdVersion(options = {}) {
2978
3964
  });
2979
3965
  return;
2980
3966
  }
2981
- console.log(`\n ${AMBER}${BOLD}⬡ triflux${RESET} ${WHITE_BRIGHT}v${PKG.version}${RESET}`);
3967
+ console.log(
3968
+ `\n ${AMBER}${BOLD}⬡ triflux${RESET} ${WHITE_BRIGHT}v${PKG.version}${RESET}`,
3969
+ );
2982
3970
  if (routeVer) console.log(` ${GRAY}tfx-route${RESET} v${routeVer}`);
2983
3971
  if (hudVer) console.log(` ${GRAY}hud${RESET} v${hudVer}`);
2984
3972
  console.log("");
@@ -3023,7 +4011,7 @@ function cmdHandoff(args = [], options = {}) {
3023
4011
  throw createCliError("--decision 값이 필요합니다", {
3024
4012
  exitCode: EXIT_ARG_ERROR,
3025
4013
  reason: "argError",
3026
- fix: "tfx handoff --decision \"결정사항\"",
4014
+ fix: 'tfx handoff --decision "결정사항"',
3027
4015
  });
3028
4016
  }
3029
4017
  parsed.decisions.push(next);
@@ -3118,7 +4106,11 @@ function cmdSchema(args = []) {
3118
4106
  $schema: bundle.$schema,
3119
4107
  title: "Triflux CLI Schema Bundle",
3120
4108
  global_options: [
3121
- { name: "--json", type: "boolean", description: "지원 커맨드의 출력을 JSON으로 전환" },
4109
+ {
4110
+ name: "--json",
4111
+ type: "boolean",
4112
+ description: "지원 커맨드의 출력을 JSON으로 전환",
4113
+ },
3122
4114
  ],
3123
4115
  commands: CLI_COMMAND_SCHEMAS,
3124
4116
  hub_tools: bundle,
@@ -3154,7 +4146,9 @@ function cmdSchema(args = []) {
3154
4146
 
3155
4147
  function cmdMcp(args = [], options = {}) {
3156
4148
  const { json = false } = options;
3157
- const sub = String(args[0] || "list").trim().toLowerCase();
4149
+ const sub = String(args[0] || "list")
4150
+ .trim()
4151
+ .toLowerCase();
3158
4152
 
3159
4153
  if (sub === "help" || sub === "--help" || sub === "-h") {
3160
4154
  console.log(`
@@ -3175,7 +4169,8 @@ function cmdMcp(args = [], options = {}) {
3175
4169
  if (json) {
3176
4170
  printJson({
3177
4171
  registry_path: registryState.path,
3178
- server_count: Object.keys(registryState.registry.servers || {}).length,
4172
+ server_count: Object.keys(registryState.registry.servers || {})
4173
+ .length,
3179
4174
  rows: statusInfo.rows,
3180
4175
  configs: statusInfo.configs.map((config) => ({
3181
4176
  file: config.filePath,
@@ -3191,7 +4186,9 @@ function cmdMcp(args = [], options = {}) {
3191
4186
  console.log(` ${LINE}`);
3192
4187
  section("Registry");
3193
4188
  info(formatPathForDisplay(registryState.path));
3194
- ok(`${Object.keys(registryState.registry.servers || {}).length}개 server 등록됨`);
4189
+ ok(
4190
+ `${Object.keys(registryState.registry.servers || {}).length}개 server 등록됨`,
4191
+ );
3195
4192
  if (statusInfo.rows.length === 0) {
3196
4193
  info("표시할 MCP 상태 없음");
3197
4194
  } else {
@@ -3222,7 +4219,8 @@ function cmdMcp(args = [], options = {}) {
3222
4219
  const label = `${action.label} ${DIM}(${formatPathForDisplay(action.filePath)})${RESET}`;
3223
4220
  if (action.status === "updated") ok(`${label} → updated`);
3224
4221
  else if (action.status === "warning") warn(`${label} → warning`);
3225
- else if (action.status === "invalid-config") fail(`${label} → invalid-config`);
4222
+ else if (action.status === "invalid-config")
4223
+ fail(`${label} → invalid-config`);
3226
4224
  else info(`${stripAnsi(label)} → ${action.status}`);
3227
4225
  }
3228
4226
  console.log("");
@@ -3248,7 +4246,9 @@ function cmdMcp(args = [], options = {}) {
3248
4246
  }
3249
4247
 
3250
4248
  const normalizedUrl = (() => {
3251
- try { return new URL(url).toString(); } catch {
4249
+ try {
4250
+ return new URL(url).toString();
4251
+ } catch {
3252
4252
  throw createCliError(`Invalid MCP URL: ${url}`, {
3253
4253
  exitCode: EXIT_ARG_ERROR,
3254
4254
  reason: "argError",
@@ -3259,7 +4259,9 @@ function cmdMcp(args = [], options = {}) {
3259
4259
 
3260
4260
  const server = addRegistryServer(name, normalizedUrl);
3261
4261
  const registryState = ensureValidRegistryState();
3262
- const syncResult = syncRegistryTargets({ registry: registryState.registry });
4262
+ const syncResult = syncRegistryTargets({
4263
+ registry: registryState.registry,
4264
+ });
3263
4265
  if (json) {
3264
4266
  printJson({
3265
4267
  name,
@@ -3273,7 +4275,9 @@ function cmdMcp(args = [], options = {}) {
3273
4275
  console.log(` ${LINE}`);
3274
4276
  ok(`${name} 등록됨`);
3275
4277
  info(normalizedUrl);
3276
- const updated = syncResult.actions.filter((action) => action.status === "updated").length;
4278
+ const updated = syncResult.actions.filter(
4279
+ (action) => action.status === "updated",
4280
+ ).length;
3277
4281
  info(`동기화 반영: ${updated}개`);
3278
4282
  console.log("");
3279
4283
  return;
@@ -3291,7 +4295,9 @@ function cmdMcp(args = [], options = {}) {
3291
4295
 
3292
4296
  ensureValidRegistryState();
3293
4297
  const removed = removeRegistryServer(name);
3294
- const cleanup = removeServerFromTargets(name, { targets: removed?.targets });
4298
+ const cleanup = removeServerFromTargets(name, {
4299
+ targets: removed?.targets,
4300
+ });
3295
4301
  if (json) {
3296
4302
  printJson({
3297
4303
  name,
@@ -3306,7 +4312,9 @@ function cmdMcp(args = [], options = {}) {
3306
4312
  console.log(` ${LINE}`);
3307
4313
  if (removed) ok(`${name} registry에서 제거됨`);
3308
4314
  else warn(`${name} registry entry 없음`);
3309
- const changed = cleanup.actions.filter((action) => action.status === "removed").length;
4315
+ const changed = cleanup.actions.filter(
4316
+ (action) => action.status === "removed",
4317
+ ).length;
3310
4318
  info(`설정 제거 반영: ${changed}개`);
3311
4319
  console.log("");
3312
4320
  return;
@@ -3345,7 +4353,10 @@ function checkForUpdate() {
3345
4353
  }).trim();
3346
4354
 
3347
4355
  if (!existsSync(cacheDir)) mkdirSync(cacheDir, { recursive: true });
3348
- writeFileSync(cacheFile, JSON.stringify({ latest: result, timestamp: Date.now() }));
4356
+ writeFileSync(
4357
+ cacheFile,
4358
+ JSON.stringify({ latest: result, timestamp: Date.now() }),
4359
+ );
3349
4360
 
3350
4361
  return result !== PKG.version ? result : null;
3351
4362
  } catch {
@@ -3404,8 +4415,21 @@ ${updateNotice}
3404
4415
  async function cmdCodexTeam(args = []) {
3405
4416
  const sub = String(args[0] || "").toLowerCase();
3406
4417
  const passthrough = new Set([
3407
- "status", "attach", "stop", "kill", "send", "list", "help", "--help", "-h",
3408
- "tasks", "task", "focus", "interrupt", "control", "debug",
4418
+ "status",
4419
+ "attach",
4420
+ "stop",
4421
+ "kill",
4422
+ "send",
4423
+ "list",
4424
+ "help",
4425
+ "--help",
4426
+ "-h",
4427
+ "tasks",
4428
+ "task",
4429
+ "focus",
4430
+ "interrupt",
4431
+ "control",
4432
+ "debug",
3409
4433
  ]);
3410
4434
 
3411
4435
  if (sub === "help" || sub === "--help" || sub === "-h") {
@@ -3428,7 +4452,8 @@ async function cmdCodexTeam(args = []) {
3428
4452
  const hasLead = args.includes("--lead");
3429
4453
  const hasLayout = args.includes("--layout");
3430
4454
  const isControl = passthrough.has(sub);
3431
- const normalizedArgs = isControl && args.length ? [sub, ...args.slice(1)] : args;
4455
+ const normalizedArgs =
4456
+ isControl && args.length ? [sub, ...args.slice(1)] : args;
3432
4457
  const inject = [];
3433
4458
  if (!isControl && !hasLead) inject.push("--lead", "codex");
3434
4459
  if (!isControl && !hasAgents) inject.push("--agents", "codex,codex");
@@ -3439,13 +4464,16 @@ async function cmdCodexTeam(args = []) {
3439
4464
  const prevProfile = process.env.TFX_TEAM_PROFILE;
3440
4465
  process.env.TFX_TEAM_PROFILE = "codex-team";
3441
4466
  const { pathToFileURL } = await import("node:url");
3442
- const { cmdTeam } = await import(pathToFileURL(join(PKG_ROOT, "hub", "team", "cli", "index.mjs")).href);
4467
+ const { cmdTeam } = await import(
4468
+ pathToFileURL(join(PKG_ROOT, "hub", "team", "cli", "index.mjs")).href
4469
+ );
3443
4470
  process.argv = [prevArgv[0], prevArgv[1], "team", ...forwarded];
3444
4471
  try {
3445
4472
  await cmdTeam();
3446
4473
  } finally {
3447
4474
  process.argv = prevArgv;
3448
- if (typeof prevProfile === "string") process.env.TFX_TEAM_PROFILE = prevProfile;
4475
+ if (typeof prevProfile === "string")
4476
+ process.env.TFX_TEAM_PROFILE = prevProfile;
3449
4477
  else delete process.env.TFX_TEAM_PROFILE;
3450
4478
  }
3451
4479
  }
@@ -3457,7 +4485,8 @@ async function checkHubRunning() {
3457
4485
  try {
3458
4486
  const cacheFile = join(homedir(), ".claude", "cache", "tfx-preflight.json");
3459
4487
  const cached = JSON.parse(readFileSync(cacheFile, "utf8"));
3460
- if (Date.now() - cached.timestamp < 3_600_000 && cached.hub?.ok) return true;
4488
+ if (Date.now() - cached.timestamp < 3_600_000 && cached.hub?.ok)
4489
+ return true;
3461
4490
  } catch {}
3462
4491
  const port = Number(process.env.TFX_HUB_PORT || "27888");
3463
4492
  try {
@@ -3468,7 +4497,9 @@ async function checkHubRunning() {
3468
4497
  } catch {}
3469
4498
  console.log("");
3470
4499
  warn(`${AMBER}tfx-hub${RESET}가 실행되고 있지 않습니다.`);
3471
- info(`Hub 없이 실행하면 Claude 네이티브 에이전트로 폴백되어 토큰이 소비됩니다.`);
4500
+ info(
4501
+ `Hub 없이 실행하면 Claude 네이티브 에이전트로 폴백되어 토큰이 소비됩니다.`,
4502
+ );
3472
4503
  info(`Codex(무료) 위임을 활용하려면 먼저 Hub를 시작하세요:\n`);
3473
4504
  console.log(` ${WHITE_BRIGHT}tfx hub start${RESET}\n`);
3474
4505
  return false;
@@ -3490,7 +4521,9 @@ function stopHubForUpdate() {
3490
4521
  info = JSON.parse(readFileSync(HUB_PID_FILE, "utf8"));
3491
4522
  process.kill(info.pid, 0);
3492
4523
  } catch {
3493
- try { unlinkSync(HUB_PID_FILE); } catch {}
4524
+ try {
4525
+ unlinkSync(HUB_PID_FILE);
4526
+ } catch {}
3494
4527
  return null;
3495
4528
  }
3496
4529
 
@@ -3505,15 +4538,28 @@ function stopHubForUpdate() {
3505
4538
  process.kill(info.pid, "SIGTERM");
3506
4539
  }
3507
4540
  } catch {
3508
- try { process.kill(info.pid, "SIGKILL"); } catch {}
4541
+ try {
4542
+ process.kill(info.pid, "SIGKILL");
4543
+ } catch {}
3509
4544
  }
3510
4545
 
3511
4546
  // Windows에서 better-sqlite3.node 파일 핸들 해제 대기
3512
4547
  // taskkill 후 프로세스 종료 + 파일 핸들 해제까지 최대 5초
3513
- const sqliteNode = join(PKG_ROOT, "node_modules", "better-sqlite3", "build", "Release", "better_sqlite3.node");
4548
+ const sqliteNode = join(
4549
+ PKG_ROOT,
4550
+ "node_modules",
4551
+ "better-sqlite3",
4552
+ "build",
4553
+ "Release",
4554
+ "better_sqlite3.node",
4555
+ );
3514
4556
  for (let i = 0; i < 10; i++) {
3515
4557
  sleepMs(500);
3516
- try { process.kill(info.pid, 0); } catch { break; }
4558
+ try {
4559
+ process.kill(info.pid, 0);
4560
+ } catch {
4561
+ break;
4562
+ }
3517
4563
  }
3518
4564
  // 파일 잠금 해제 확인 (Windows EBUSY 방지)
3519
4565
  if (existsSync(sqliteNode)) {
@@ -3527,7 +4573,9 @@ function stopHubForUpdate() {
3527
4573
  }
3528
4574
  }
3529
4575
  }
3530
- try { unlinkSync(HUB_PID_FILE); } catch {}
4576
+ try {
4577
+ unlinkSync(HUB_PID_FILE);
4578
+ } catch {}
3531
4579
  return info;
3532
4580
  }
3533
4581
 
@@ -3535,7 +4583,10 @@ function startHubAfterUpdate(info) {
3535
4583
  if (!info) return false;
3536
4584
  const serverPath = join(PKG_ROOT, "hub", "server.mjs");
3537
4585
  if (!existsSync(serverPath)) return false;
3538
- const port = Number(info?.port) > 0 ? String(info.port) : String(process.env.TFX_HUB_PORT || "27888");
4586
+ const port =
4587
+ Number(info?.port) > 0
4588
+ ? String(info.port)
4589
+ : String(process.env.TFX_HUB_PORT || "27888");
3539
4590
 
3540
4591
  try {
3541
4592
  const child = spawn(process.execPath, [serverPath], {
@@ -3558,14 +4609,24 @@ function autoRegisterMcp(mcpUrl, { codexEnabled = false } = {}) {
3558
4609
  // Codex — config.json에 기본 disabled 엔트리로 등록
3559
4610
  if (which("codex")) {
3560
4611
  try {
3561
- const result = ensureCodexHubServerConfig({ mcpUrl, createIfMissing: true, enabled: codexEnabled });
4612
+ const result = ensureCodexHubServerConfig({
4613
+ mcpUrl,
4614
+ createIfMissing: true,
4615
+ enabled: codexEnabled,
4616
+ });
3562
4617
  if (!result.ok) throw new Error(result.reason || "unknown");
3563
4618
  if (result.changed) {
3564
- ok(`Codex: config.json에 등록 완료 (${codexEnabled ? "enabled" : "기본 disabled"})`);
4619
+ ok(
4620
+ `Codex: config.json에 등록 완료 (${codexEnabled ? "enabled" : "기본 disabled"})`,
4621
+ );
3565
4622
  } else {
3566
- ok(`Codex: 이미 등록됨 (${codexEnabled ? "enabled" : "기본 disabled"})`);
4623
+ ok(
4624
+ `Codex: 이미 등록됨 (${codexEnabled ? "enabled" : "기본 disabled"})`,
4625
+ );
3567
4626
  }
3568
- } catch (e) { warn(`Codex 등록 실패: ${e.message}`); }
4627
+ } catch (e) {
4628
+ warn(`Codex 등록 실패: ${e.message}`);
4629
+ }
3569
4630
  } else {
3570
4631
  info("Codex: 미설치 (건너뜀)");
3571
4632
  }
@@ -3576,7 +4637,8 @@ function autoRegisterMcp(mcpUrl, { codexEnabled = false } = {}) {
3576
4637
  const geminiDir = join(homedir(), ".gemini");
3577
4638
  const settingsFile = join(geminiDir, "settings.json");
3578
4639
  let settings = {};
3579
- if (existsSync(settingsFile)) settings = JSON.parse(readFileSync(settingsFile, "utf8"));
4640
+ if (existsSync(settingsFile))
4641
+ settings = JSON.parse(readFileSync(settingsFile, "utf8"));
3580
4642
  if (!settings.mcpServers) settings.mcpServers = {};
3581
4643
  if (!settings.mcpServers["tfx-hub"]) {
3582
4644
  settings.mcpServers["tfx-hub"] = { url: mcpUrl };
@@ -3586,7 +4648,9 @@ function autoRegisterMcp(mcpUrl, { codexEnabled = false } = {}) {
3586
4648
  } else {
3587
4649
  ok("Gemini: 이미 등록됨");
3588
4650
  }
3589
- } catch (e) { warn(`Gemini 등록 실패: ${e.message}`); }
4651
+ } catch (e) {
4652
+ warn(`Gemini 등록 실패: ${e.message}`);
4653
+ }
3590
4654
  } else {
3591
4655
  info("Gemini: 미설치 (건너뜀)");
3592
4656
  }
@@ -3597,7 +4661,8 @@ function autoRegisterMcp(mcpUrl, { codexEnabled = false } = {}) {
3597
4661
  if (!existsSync(claudeDir)) mkdirSync(claudeDir, { recursive: true });
3598
4662
  const mcpJsonPath = join(claudeDir, "mcp.json");
3599
4663
  let mcpJson = {};
3600
- if (existsSync(mcpJsonPath)) mcpJson = JSON.parse(readFileSync(mcpJsonPath, "utf8"));
4664
+ if (existsSync(mcpJsonPath))
4665
+ mcpJson = JSON.parse(readFileSync(mcpJsonPath, "utf8"));
3601
4666
  if (!mcpJson.mcpServers) mcpJson.mcpServers = {};
3602
4667
  if (!mcpJson.mcpServers["tfx-hub"]) {
3603
4668
  mcpJson.mcpServers["tfx-hub"] = { type: "url", url: mcpUrl };
@@ -3606,20 +4671,32 @@ function autoRegisterMcp(mcpUrl, { codexEnabled = false } = {}) {
3606
4671
  } else {
3607
4672
  ok("Claude: 이미 등록됨");
3608
4673
  }
3609
- } catch (e) { warn(`Claude 등록 실패: ${e.message}`); }
4674
+ } catch (e) {
4675
+ warn(`Claude 등록 실패: ${e.message}`);
4676
+ }
3610
4677
  }
3611
4678
 
3612
4679
  async function cmdHub(args = [], options = {}) {
3613
4680
  const { json = false } = options;
3614
4681
  const sub = args[0] || "status";
3615
4682
  const defaultPortRaw = Number(process.env.TFX_HUB_PORT || "27888");
3616
- const probePort = Number.isFinite(defaultPortRaw) && defaultPortRaw > 0 ? defaultPortRaw : 27888;
3617
- const formatHostForUrl = (host) => host.includes(":") ? `[${host}]` : host;
3618
- const probeHubStatus = async (host = "127.0.0.1", port = probePort, timeoutMs = 3000) => {
4683
+ const probePort =
4684
+ Number.isFinite(defaultPortRaw) && defaultPortRaw > 0
4685
+ ? defaultPortRaw
4686
+ : 27888;
4687
+ const formatHostForUrl = (host) => (host.includes(":") ? `[${host}]` : host);
4688
+ const probeHubStatus = async (
4689
+ host = "127.0.0.1",
4690
+ port = probePort,
4691
+ timeoutMs = 3000,
4692
+ ) => {
3619
4693
  try {
3620
- const res = await fetch(`http://${formatHostForUrl(host)}:${port}/status`, {
3621
- signal: AbortSignal.timeout(timeoutMs),
3622
- });
4694
+ const res = await fetch(
4695
+ `http://${formatHostForUrl(host)}:${port}/status`,
4696
+ {
4697
+ signal: AbortSignal.timeout(timeoutMs),
4698
+ },
4699
+ );
3623
4700
  if (!res.ok) return null;
3624
4701
  const data = await res.json();
3625
4702
  return data?.hub ? data : null;
@@ -3633,13 +4710,16 @@ async function cmdHub(args = [], options = {}) {
3633
4710
  if (!Number.isFinite(pid) || pid <= 0) return;
3634
4711
  try {
3635
4712
  mkdirSync(HUB_PID_DIR, { recursive: true });
3636
- writeFileSync(HUB_PID_FILE, JSON.stringify({
3637
- pid,
3638
- port,
3639
- host: defaultHost,
3640
- url: `http://${formatHostForUrl(defaultHost)}:${port}/mcp`,
3641
- started: Date.now(),
3642
- }));
4713
+ writeFileSync(
4714
+ HUB_PID_FILE,
4715
+ JSON.stringify({
4716
+ pid,
4717
+ port,
4718
+ host: defaultHost,
4719
+ url: `http://${formatHostForUrl(defaultHost)}:${port}/mcp`,
4720
+ started: Date.now(),
4721
+ }),
4722
+ );
3643
4723
  } catch {}
3644
4724
  };
3645
4725
  const emitHubStatus = (payload) => {
@@ -3656,11 +4736,15 @@ async function cmdHub(args = [], options = {}) {
3656
4736
  const info = JSON.parse(readFileSync(HUB_PID_FILE, "utf8"));
3657
4737
  process.kill(info.pid, 0); // 프로세스 존재 확인
3658
4738
  autoRegisterMcp(info.url, { codexEnabled: true });
3659
- console.log(`\n ${YELLOW}⚠${RESET} hub 이미 실행 중 (PID ${info.pid}, ${info.url})\n`);
4739
+ console.log(
4740
+ `\n ${YELLOW}⚠${RESET} hub 이미 실행 중 (PID ${info.pid}, ${info.url})\n`,
4741
+ );
3660
4742
  return;
3661
4743
  } catch {
3662
4744
  // PID 파일 있지만 프로세스 없음 — 정리
3663
- try { unlinkSync(HUB_PID_FILE); } catch {}
4745
+ try {
4746
+ unlinkSync(HUB_PID_FILE);
4747
+ } catch {}
3664
4748
  }
3665
4749
  }
3666
4750
 
@@ -3688,7 +4772,10 @@ async function cmdHub(args = [], options = {}) {
3688
4772
  let started = false;
3689
4773
  const deadline = Date.now() + 3000;
3690
4774
  while (Date.now() < deadline) {
3691
- if (existsSync(HUB_PID_FILE)) { started = true; break; }
4775
+ if (existsSync(HUB_PID_FILE)) {
4776
+ started = true;
4777
+ break;
4778
+ }
3692
4779
  await new Promise((r) => setTimeout(r, 100));
3693
4780
  }
3694
4781
 
@@ -3697,26 +4784,37 @@ async function cmdHub(args = [], options = {}) {
3697
4784
  console.log(`\n ${GREEN_BRIGHT}✓${RESET} ${BOLD}tfx-hub 시작${RESET}`);
3698
4785
  console.log(` URL: ${AMBER}${hubInfo.url}${RESET}`);
3699
4786
  console.log(` PID: ${hubInfo.pid}`);
3700
- console.log(` DB: ${DIM}${getPipelineStateDbPath(PKG_ROOT)}${RESET}`);
4787
+ console.log(
4788
+ ` DB: ${DIM}${getPipelineStateDbPath(PKG_ROOT)}${RESET}`,
4789
+ );
3701
4790
  console.log("");
3702
4791
  autoRegisterMcp(hubInfo.url, { codexEnabled: true });
3703
4792
  console.log("");
3704
4793
  } else {
3705
4794
  // 직접 포그라운드 모드로 안내
3706
- console.log(`\n ${YELLOW}⚠${RESET} 백그라운드 시작 실패 — 포그라운드로 실행:`);
3707
- console.log(` ${DIM}TFX_HUB_PORT=${port} node ${serverPath}${RESET}\n`);
4795
+ console.log(
4796
+ `\n ${YELLOW}⚠${RESET} 백그라운드 시작 실패 — 포그라운드로 실행:`,
4797
+ );
4798
+ console.log(
4799
+ ` ${DIM}TFX_HUB_PORT=${port} node ${serverPath}${RESET}\n`,
4800
+ );
3708
4801
  }
3709
4802
  break;
3710
4803
  }
3711
4804
 
3712
4805
  case "stop": {
3713
4806
  if (!existsSync(HUB_PID_FILE)) {
3714
- const probed = await probeHubStatus("127.0.0.1", probePort, 1500)
3715
- || (probePort === 27888 ? null : await probeHubStatus("127.0.0.1", 27888, 1500));
4807
+ const probed =
4808
+ (await probeHubStatus("127.0.0.1", probePort, 1500)) ||
4809
+ (probePort === 27888
4810
+ ? null
4811
+ : await probeHubStatus("127.0.0.1", 27888, 1500));
3716
4812
  if (probed && Number.isFinite(Number(probed.pid))) {
3717
4813
  try {
3718
4814
  process.kill(Number(probed.pid), "SIGTERM");
3719
- console.log(`\n ${GREEN_BRIGHT}✓${RESET} hub 종료됨 (PID ${probed.pid})${DIM} (probe)${RESET}\n`);
4815
+ console.log(
4816
+ `\n ${GREEN_BRIGHT}✓${RESET} hub 종료됨 (PID ${probed.pid})${DIM} (probe)${RESET}\n`,
4817
+ );
3720
4818
  return;
3721
4819
  } catch {}
3722
4820
  }
@@ -3726,10 +4824,16 @@ async function cmdHub(args = [], options = {}) {
3726
4824
  try {
3727
4825
  const info = JSON.parse(readFileSync(HUB_PID_FILE, "utf8"));
3728
4826
  process.kill(info.pid, "SIGTERM");
3729
- try { unlinkSync(HUB_PID_FILE); } catch {}
3730
- console.log(`\n ${GREEN_BRIGHT}✓${RESET} hub 종료됨 (PID ${info.pid})\n`);
4827
+ try {
4828
+ unlinkSync(HUB_PID_FILE);
4829
+ } catch {}
4830
+ console.log(
4831
+ `\n ${GREEN_BRIGHT}✓${RESET} hub 종료됨 (PID ${info.pid})\n`,
4832
+ );
3731
4833
  } catch (_e) {
3732
- try { unlinkSync(HUB_PID_FILE); } catch {}
4834
+ try {
4835
+ unlinkSync(HUB_PID_FILE);
4836
+ } catch {}
3733
4837
  console.log(`\n ${DIM}hub 프로세스 없음 — PID 파일 정리됨${RESET}\n`);
3734
4838
  }
3735
4839
  break;
@@ -3739,43 +4843,76 @@ async function cmdHub(args = [], options = {}) {
3739
4843
  if (!existsSync(HUB_PID_FILE)) {
3740
4844
  const probed = await probeHubStatus();
3741
4845
  if (!probed) {
3742
- const fallback = probePort === 27888 ? null : await probeHubStatus("127.0.0.1", 27888, 1500);
4846
+ const fallback =
4847
+ probePort === 27888
4848
+ ? null
4849
+ : await probeHubStatus("127.0.0.1", 27888, 1500);
3743
4850
  if (fallback) {
3744
4851
  recoverPidFile(fallback, "127.0.0.1");
3745
- if (emitHubStatus({
3746
- status: "online",
3747
- source: "default-port-probe",
3748
- url: `http://127.0.0.1:${fallback.port || 27888}/mcp`,
3749
- pid: fallback.pid,
3750
- state: fallback.hub?.state || null,
3751
- sessions: fallback.sessions,
3752
- })) return;
3753
- console.log(`\n ${AMBER}${BOLD}⬡ tfx-hub${RESET} ${GREEN_BRIGHT}online${RESET} ${DIM}(default port probe 성공)${RESET}`);
3754
- console.log(` URL: http://127.0.0.1:${fallback.port || 27888}/mcp`);
3755
- if (fallback.pid !== undefined) console.log(` PID: ${fallback.pid}`);
3756
- if (fallback.hub?.state) console.log(` State: ${fallback.hub.state}`);
3757
- if (fallback.sessions !== undefined) console.log(` Sessions: ${fallback.sessions}`);
4852
+ if (
4853
+ emitHubStatus({
4854
+ status: "online",
4855
+ source: "default-port-probe",
4856
+ url: `http://127.0.0.1:${fallback.port || 27888}/mcp`,
4857
+ pid: fallback.pid,
4858
+ state: fallback.hub?.state || null,
4859
+ sessions: fallback.sessions,
4860
+ })
4861
+ )
4862
+ return;
4863
+ console.log(
4864
+ `\n ${AMBER}${BOLD}⬡ tfx-hub${RESET} ${GREEN_BRIGHT}online${RESET} ${DIM}(default port probe 성공)${RESET}`,
4865
+ );
4866
+ console.log(
4867
+ ` URL: http://127.0.0.1:${fallback.port || 27888}/mcp`,
4868
+ );
4869
+ if (fallback.pid !== undefined)
4870
+ console.log(` PID: ${fallback.pid}`);
4871
+ if (fallback.hub?.state)
4872
+ console.log(` State: ${fallback.hub.state}`);
4873
+ if (fallback.sessions !== undefined)
4874
+ console.log(` Sessions: ${fallback.sessions}`);
3758
4875
  console.log("");
3759
4876
  return;
3760
4877
  }
3761
- if (emitHubStatus({ status: "offline", source: "probe", url: null, pid: null, state: null, sessions: 0 })) return;
3762
- console.log(`\n ${AMBER}${BOLD}⬡ tfx-hub${RESET} ${RED}offline${RESET}\n`);
4878
+ if (
4879
+ emitHubStatus({
4880
+ status: "offline",
4881
+ source: "probe",
4882
+ url: null,
4883
+ pid: null,
4884
+ state: null,
4885
+ sessions: 0,
4886
+ })
4887
+ )
4888
+ return;
4889
+ console.log(
4890
+ `\n ${AMBER}${BOLD}⬡ tfx-hub${RESET} ${RED}offline${RESET}\n`,
4891
+ );
3763
4892
  return;
3764
4893
  }
3765
4894
  recoverPidFile(probed, "127.0.0.1");
3766
- if (emitHubStatus({
3767
- status: "online",
3768
- source: "probe",
3769
- url: `http://127.0.0.1:${probed.port || probePort}/mcp`,
3770
- pid: probed.pid,
3771
- state: probed.hub?.state || null,
3772
- sessions: probed.sessions,
3773
- })) return;
3774
- console.log(`\n ${AMBER}${BOLD}⬡ tfx-hub${RESET} ${GREEN_BRIGHT}online${RESET} ${DIM}(pid file 없음 / probe 성공)${RESET}`);
3775
- console.log(` URL: http://127.0.0.1:${probed.port || probePort}/mcp`);
4895
+ if (
4896
+ emitHubStatus({
4897
+ status: "online",
4898
+ source: "probe",
4899
+ url: `http://127.0.0.1:${probed.port || probePort}/mcp`,
4900
+ pid: probed.pid,
4901
+ state: probed.hub?.state || null,
4902
+ sessions: probed.sessions,
4903
+ })
4904
+ )
4905
+ return;
4906
+ console.log(
4907
+ `\n ${AMBER}${BOLD}⬡ tfx-hub${RESET} ${GREEN_BRIGHT}online${RESET} ${DIM}(pid file 없음 / probe 성공)${RESET}`,
4908
+ );
4909
+ console.log(
4910
+ ` URL: http://127.0.0.1:${probed.port || probePort}/mcp`,
4911
+ );
3776
4912
  if (probed.pid !== undefined) console.log(` PID: ${probed.pid}`);
3777
4913
  if (probed.hub?.state) console.log(` State: ${probed.hub.state}`);
3778
- if (probed.sessions !== undefined) console.log(` Sessions: ${probed.sessions}`);
4914
+ if (probed.sessions !== undefined)
4915
+ console.log(` Sessions: ${probed.sessions}`);
3779
4916
  console.log("");
3780
4917
  return;
3781
4918
  }
@@ -3783,9 +4920,12 @@ async function cmdHub(args = [], options = {}) {
3783
4920
  const info = JSON.parse(readFileSync(HUB_PID_FILE, "utf8"));
3784
4921
  process.kill(info.pid, 0); // 생존 확인
3785
4922
  const uptime = Date.now() - info.started;
3786
- const uptimeStr = uptime < 60000 ? `${Math.round(uptime / 1000)}초`
3787
- : uptime < 3600000 ? `${Math.round(uptime / 60000)}분`
3788
- : `${Math.round(uptime / 3600000)}시간`;
4923
+ const uptimeStr =
4924
+ uptime < 60000
4925
+ ? `${Math.round(uptime / 1000)}초`
4926
+ : uptime < 3600000
4927
+ ? `${Math.round(uptime / 60000)}분`
4928
+ : `${Math.round(uptime / 3600000)}시간`;
3789
4929
 
3790
4930
  let data = null;
3791
4931
  try {
@@ -3794,16 +4934,21 @@ async function cmdHub(args = [], options = {}) {
3794
4934
  data = await probeHubStatus(host, port, 3000);
3795
4935
  } catch {}
3796
4936
 
3797
- if (emitHubStatus({
3798
- status: "online",
3799
- source: "pid-file",
3800
- url: info.url,
3801
- pid: info.pid,
3802
- uptime_ms: uptime,
3803
- state: data?.hub?.state || null,
3804
- sessions: data?.sessions,
3805
- })) return;
3806
- console.log(`\n ${AMBER}${BOLD}⬡ tfx-hub${RESET} ${GREEN_BRIGHT}online${RESET}`);
4937
+ if (
4938
+ emitHubStatus({
4939
+ status: "online",
4940
+ source: "pid-file",
4941
+ url: info.url,
4942
+ pid: info.pid,
4943
+ uptime_ms: uptime,
4944
+ state: data?.hub?.state || null,
4945
+ sessions: data?.sessions,
4946
+ })
4947
+ )
4948
+ return;
4949
+ console.log(
4950
+ `\n ${AMBER}${BOLD}⬡ tfx-hub${RESET} ${GREEN_BRIGHT}online${RESET}`,
4951
+ );
3807
4952
  console.log(` URL: ${info.url}`);
3808
4953
  console.log(` PID: ${info.pid}`);
3809
4954
  console.log(` Uptime: ${uptimeStr}`);
@@ -3815,27 +4960,49 @@ async function cmdHub(args = [], options = {}) {
3815
4960
  }
3816
4961
  console.log("");
3817
4962
  } catch {
3818
- try { unlinkSync(HUB_PID_FILE); } catch {}
4963
+ try {
4964
+ unlinkSync(HUB_PID_FILE);
4965
+ } catch {}
3819
4966
  const probed = await probeHubStatus();
3820
4967
  if (!probed) {
3821
- if (emitHubStatus({ status: "offline", source: "stale-pid", url: null, pid: null, state: null, sessions: 0 })) break;
3822
- console.log(`\n ${AMBER}${BOLD}⬡ tfx-hub${RESET} ${RED}offline${RESET} ${DIM}(stale PID 정리됨)${RESET}\n`);
4968
+ if (
4969
+ emitHubStatus({
4970
+ status: "offline",
4971
+ source: "stale-pid",
4972
+ url: null,
4973
+ pid: null,
4974
+ state: null,
4975
+ sessions: 0,
4976
+ })
4977
+ )
4978
+ break;
4979
+ console.log(
4980
+ `\n ${AMBER}${BOLD}⬡ tfx-hub${RESET} ${RED}offline${RESET} ${DIM}(stale PID 정리됨)${RESET}\n`,
4981
+ );
3823
4982
  break;
3824
4983
  }
3825
4984
  recoverPidFile(probed, "127.0.0.1");
3826
- if (emitHubStatus({
3827
- status: "online",
3828
- source: "stale-pid-probe",
3829
- url: `http://127.0.0.1:${probed.port || probePort}/mcp`,
3830
- pid: probed.pid,
3831
- state: probed.hub?.state || null,
3832
- sessions: probed.sessions,
3833
- })) break;
3834
- console.log(`\n ${AMBER}${BOLD}⬡ tfx-hub${RESET} ${GREEN_BRIGHT}online${RESET} ${DIM}(stale PID 정리 후 probe 성공)${RESET}`);
3835
- console.log(` URL: http://127.0.0.1:${probed.port || probePort}/mcp`);
4985
+ if (
4986
+ emitHubStatus({
4987
+ status: "online",
4988
+ source: "stale-pid-probe",
4989
+ url: `http://127.0.0.1:${probed.port || probePort}/mcp`,
4990
+ pid: probed.pid,
4991
+ state: probed.hub?.state || null,
4992
+ sessions: probed.sessions,
4993
+ })
4994
+ )
4995
+ break;
4996
+ console.log(
4997
+ `\n ${AMBER}${BOLD}⬡ tfx-hub${RESET} ${GREEN_BRIGHT}online${RESET} ${DIM}(stale PID 정리 후 probe 성공)${RESET}`,
4998
+ );
4999
+ console.log(
5000
+ ` URL: http://127.0.0.1:${probed.port || probePort}/mcp`,
5001
+ );
3836
5002
  if (probed.pid !== undefined) console.log(` PID: ${probed.pid}`);
3837
5003
  if (probed.hub?.state) console.log(` State: ${probed.hub.state}`);
3838
- if (probed.sessions !== undefined) console.log(` Sessions: ${probed.sessions}`);
5004
+ if (probed.sessions !== undefined)
5005
+ console.log(` Sessions: ${probed.sessions}`);
3839
5006
  console.log("");
3840
5007
  }
3841
5008
  break;
@@ -3845,12 +5012,24 @@ async function cmdHub(args = [], options = {}) {
3845
5012
  // 사일런트 idempotent 보장 — 스킬 환경 프로브용.
3846
5013
  // Hub 살아있으면 즉시 종료, 죽어있으면 자동 시작 + ready 대기.
3847
5014
  const portArg = args.indexOf("--port");
3848
- const ensurePort = portArg !== -1 ? args[portArg + 1] : (process.env.TFX_HUB_PORT || "27888");
5015
+ const ensurePort =
5016
+ portArg !== -1
5017
+ ? args[portArg + 1]
5018
+ : process.env.TFX_HUB_PORT || "27888";
3849
5019
 
3850
5020
  // 1. 이미 healthy?
3851
- const ensureProbed = await probeHubStatus("127.0.0.1", Number(ensurePort), 1500);
5021
+ const ensureProbed = await probeHubStatus(
5022
+ "127.0.0.1",
5023
+ Number(ensurePort),
5024
+ 1500,
5025
+ );
3852
5026
  if (ensureProbed?.hub?.state === "healthy") {
3853
- if (json) printJson({ status: "ok", pid: ensureProbed.pid, port: Number(ensurePort) });
5027
+ if (json)
5028
+ printJson({
5029
+ status: "ok",
5030
+ pid: ensureProbed.pid,
5031
+ port: Number(ensurePort),
5032
+ });
3854
5033
  else process.stdout.write("hub: ok\n");
3855
5034
  return;
3856
5035
  }
@@ -3864,15 +5043,26 @@ async function cmdHub(args = [], options = {}) {
3864
5043
  const retryDeadline = Date.now() + 3000;
3865
5044
  while (Date.now() < retryDeadline) {
3866
5045
  await new Promise((r) => setTimeout(r, 250));
3867
- const retry = await probeHubStatus("127.0.0.1", Number(ensurePort), 1000);
5046
+ const retry = await probeHubStatus(
5047
+ "127.0.0.1",
5048
+ Number(ensurePort),
5049
+ 1000,
5050
+ );
3868
5051
  if (retry?.hub?.state === "healthy") {
3869
- if (json) printJson({ status: "ok", pid: retry.pid, port: Number(ensurePort) });
5052
+ if (json)
5053
+ printJson({
5054
+ status: "ok",
5055
+ pid: retry.pid,
5056
+ port: Number(ensurePort),
5057
+ });
3870
5058
  else process.stdout.write("hub: ok\n");
3871
5059
  return;
3872
5060
  }
3873
5061
  }
3874
5062
  } catch {
3875
- try { unlinkSync(HUB_PID_FILE); } catch {}
5063
+ try {
5064
+ unlinkSync(HUB_PID_FILE);
5065
+ } catch {}
3876
5066
  }
3877
5067
  }
3878
5068
 
@@ -3886,11 +5076,15 @@ async function cmdHub(args = [], options = {}) {
3886
5076
  }
3887
5077
 
3888
5078
  if (process.platform === "win32") {
3889
- const child = spawn("cmd.exe", ["/c", "start", "/b", "", process.execPath, serverPath], {
3890
- env: { ...process.env, TFX_HUB_PORT: String(ensurePort) },
3891
- stdio: "ignore",
3892
- windowsHide: true,
3893
- });
5079
+ const child = spawn(
5080
+ "cmd.exe",
5081
+ ["/c", "start", "/b", "", process.execPath, serverPath],
5082
+ {
5083
+ env: { ...process.env, TFX_HUB_PORT: String(ensurePort) },
5084
+ stdio: "ignore",
5085
+ windowsHide: true,
5086
+ },
5087
+ );
3894
5088
  child.unref();
3895
5089
  } else {
3896
5090
  const child = spawn(process.execPath, [serverPath], {
@@ -3906,9 +5100,19 @@ async function cmdHub(args = [], options = {}) {
3906
5100
  while (Date.now() < readyDeadline) {
3907
5101
  await new Promise((r) => setTimeout(r, 250));
3908
5102
  if (existsSync(HUB_PID_FILE)) {
3909
- const readyProbe = await probeHubStatus("127.0.0.1", Number(ensurePort), 1000);
5103
+ const readyProbe = await probeHubStatus(
5104
+ "127.0.0.1",
5105
+ Number(ensurePort),
5106
+ 1000,
5107
+ );
3910
5108
  if (readyProbe?.hub?.state === "healthy") {
3911
- if (json) printJson({ status: "ok", pid: readyProbe.pid, port: Number(ensurePort), started: true });
5109
+ if (json)
5110
+ printJson({
5111
+ status: "ok",
5112
+ pid: readyProbe.pid,
5113
+ port: Number(ensurePort),
5114
+ started: true,
5115
+ });
3912
5116
  else process.stdout.write("hub: started\n");
3913
5117
  return;
3914
5118
  }
@@ -3923,11 +5127,21 @@ async function cmdHub(args = [], options = {}) {
3923
5127
 
3924
5128
  default:
3925
5129
  console.log(`\n ${AMBER}${BOLD}⬡ tfx-hub${RESET}\n`);
3926
- console.log(` ${WHITE_BRIGHT}tfx hub start${RESET} ${GRAY}허브 데몬 시작${RESET}`);
3927
- console.log(` ${DIM} --port N${RESET} ${GRAY}포트 지정 (기본 27888)${RESET}`);
3928
- console.log(` ${WHITE_BRIGHT}tfx hub stop${RESET} ${GRAY}허브 중지${RESET}`);
3929
- console.log(` ${WHITE_BRIGHT}tfx hub status${RESET} ${GRAY}상태 확인${RESET}`);
3930
- console.log(` ${WHITE_BRIGHT}tfx hub ensure${RESET} ${GRAY}헬스체크 + 자동 시작 (스킬 프로브용)${RESET}\n`);
5130
+ console.log(
5131
+ ` ${WHITE_BRIGHT}tfx hub start${RESET} ${GRAY}허브 데몬 시작${RESET}`,
5132
+ );
5133
+ console.log(
5134
+ ` ${DIM} --port N${RESET} ${GRAY}포트 지정 (기본 27888)${RESET}`,
5135
+ );
5136
+ console.log(
5137
+ ` ${WHITE_BRIGHT}tfx hub stop${RESET} ${GRAY}허브 중지${RESET}`,
5138
+ );
5139
+ console.log(
5140
+ ` ${WHITE_BRIGHT}tfx hub status${RESET} ${GRAY}상태 확인${RESET}`,
5141
+ );
5142
+ console.log(
5143
+ ` ${WHITE_BRIGHT}tfx hub ensure${RESET} ${GRAY}헬스체크 + 자동 시작 (스킬 프로브용)${RESET}\n`,
5144
+ );
3931
5145
  }
3932
5146
  }
3933
5147
 
@@ -3946,6 +5160,33 @@ async function main() {
3946
5160
  cmdSetup({ dryRun: cmdArgs.includes("--dry-run") });
3947
5161
  return;
3948
5162
  case "doctor": {
5163
+ if (cmdArgs.includes("--audit")) {
5164
+ const auditScript = join(PKG_ROOT, "scripts", "config-audit.mjs");
5165
+ const auditArgs = JSON_OUTPUT ? ["--json"] : [];
5166
+ try {
5167
+ const out = execFileSync(process.execPath, [auditScript, ...auditArgs], {
5168
+ timeout: 15000, encoding: "utf8", windowsHide: true,
5169
+ });
5170
+ process.stdout.write(out);
5171
+ } catch (e) {
5172
+ process.stdout.write(e.stdout || "");
5173
+ if (e.stderr) process.stderr.write(e.stderr);
5174
+ }
5175
+ return;
5176
+ }
5177
+ if (cmdArgs.includes("--diagnose")) {
5178
+ const { diagnose } = await import("../scripts/doctor-diagnose.mjs");
5179
+ const result = await diagnose({ json: JSON_OUTPUT });
5180
+ if (!JSON_OUTPUT) {
5181
+ if (result.ok) {
5182
+ console.log(`\n ${GREEN_BRIGHT}✓${RESET} 진단 번들 생성: ${result.zipPath}`);
5183
+ console.log(` spawn 이벤트: ${result.traceCount}건, 훅 타이밍: ${result.hookTimingCount}건\n`);
5184
+ } else {
5185
+ console.log(`\n ${RED}✗${RESET} 진단 실패: ${result.error}\n`);
5186
+ }
5187
+ }
5188
+ return;
5189
+ }
3949
5190
  const fix = cmdArgs.includes("--fix");
3950
5191
  const reset = cmdArgs.includes("--reset");
3951
5192
  await cmdDoctor({ fix, reset, json: JSON_OUTPUT });
@@ -3968,7 +5209,10 @@ async function main() {
3968
5209
  cmdHandoff(cmdArgs, { json: JSON_OUTPUT });
3969
5210
  return;
3970
5211
  case "hub":
3971
- await cmdHub(cmdArgs, { json: JSON_OUTPUT && ["status", "ensure"].includes(cmdArgs[0] || "status") });
5212
+ await cmdHub(cmdArgs, {
5213
+ json:
5214
+ JSON_OUTPUT && ["status", "ensure"].includes(cmdArgs[0] || "status"),
5215
+ });
3972
5216
  return;
3973
5217
  case "monitor": {
3974
5218
  const { createMonitor } = await import("../tui/monitor.mjs");
@@ -3992,7 +5236,9 @@ async function main() {
3992
5236
  windowsHide: true,
3993
5237
  });
3994
5238
  child.unref();
3995
- console.log(`\n ${GREEN_BRIGHT}✓${RESET} tray 시작됨 (PID ${child.pid})\n`);
5239
+ console.log(
5240
+ `\n ${GREEN_BRIGHT}✓${RESET} tray 시작됨 (PID ${child.pid})\n`,
5241
+ );
3996
5242
  return;
3997
5243
  }
3998
5244
  case "multi": {
@@ -4003,7 +5249,9 @@ async function main() {
4003
5249
  await checkHubRunning();
4004
5250
  }
4005
5251
  const { pathToFileURL } = await import("node:url");
4006
- const { cmdTeam } = await import(pathToFileURL(join(PKG_ROOT, "hub", "team", "cli", "index.mjs")).href);
5252
+ const { cmdTeam } = await import(
5253
+ pathToFileURL(join(PKG_ROOT, "hub", "team", "cli", "index.mjs")).href
5254
+ );
4007
5255
  const prevArgv = process.argv;
4008
5256
  process.argv = [prevArgv[0], prevArgv[1], "team", ...cmdArgs];
4009
5257
  try {
@@ -4028,7 +5276,11 @@ async function main() {
4028
5276
  case "nr": {
4029
5277
  const scriptPath = join(PKG_ROOT, "scripts", "notion-read.mjs");
4030
5278
  try {
4031
- execFileSync(process.execPath, [scriptPath, ...cmdArgs], { stdio: "inherit", timeout: 660000, windowsHide: true });
5279
+ execFileSync(process.execPath, [scriptPath, ...cmdArgs], {
5280
+ stdio: "inherit",
5281
+ timeout: 660000,
5282
+ windowsHide: true,
5283
+ });
4032
5284
  } catch (e) {
4033
5285
  throw createCliError(e.message || "notion-read 실행 실패", {
4034
5286
  exitCode: e.status || EXIT_ERROR,
@@ -4041,11 +5293,15 @@ async function main() {
4041
5293
  const hookManagerPath = join(PKG_ROOT, "hooks", "hook-manager.mjs");
4042
5294
  const sub = cmdArgs[0] || "status";
4043
5295
  try {
4044
- execFileSync(process.execPath, [hookManagerPath, sub, ...cmdArgs.slice(1)], {
4045
- stdio: "inherit",
4046
- timeout: 30000,
4047
- windowsHide: true,
4048
- });
5296
+ execFileSync(
5297
+ process.execPath,
5298
+ [hookManagerPath, sub, ...cmdArgs.slice(1)],
5299
+ {
5300
+ stdio: "inherit",
5301
+ timeout: 30000,
5302
+ windowsHide: true,
5303
+ },
5304
+ );
4049
5305
  } catch (e) {
4050
5306
  if (e.status) process.exitCode = e.status;
4051
5307
  }