The first thing to know is that almost everything you can do with a task can be done with a script, and vice versa. But there are things that can only be done with tasks or only with scripts, and things that are possible with both but easier with one of them.
Tasks
The most important thing you can do with a task but not with a script is to override another task. For example, you can do something before running your tests:
task("test", async (args, hre, runSuper) => {
// do something
return runSuper()
})
What about custom tasks? In that case, there's nothing you can do with a task that can't be done with a script. The main reason to use a task then is to plug into Hardhat's argument parser. So if you are working on a token and want to check the balance of some address in some network for that token, you could do something like:
hh balance --address 00x4403B5d2Fed270D18b6d83122C818c2413D9BC05 --network mainnet
(where hh is the hardhat shorthand).
Scripts
One thing you can do with scripts but not with tasks is to run them directly with node. That is, instead of
hh run my-script.js
you can do
node my-script.js
So you can do things like passing extra flags to the node binary, for example. This also lets you execute the script with another binary, like ts-node, ndb or mocha.
Keep in mind that if you do this you need to explicitly import the Hardhat Runtime Environment:
const hre = require("hardhat")
Alternatively, you can do:
node --require hardhat/register my-scripts.js
to have the same globally available variables that you get when you use hh run.
Another reason to use scripts is to parse arguments with a different library, like commander, instead of using Hardhat's own parser.
Notice that if you do this, you won't be able to specify the network anymore with the --network param. In that case, you need to set the HARDHAT_NETWORK environment variable:
HARDHAT_NETWORK=rinkeby node my-scripts.js