Ok, so here's a bit of a proof-of-concept. It's using a (very lightly) modified Kaboom for the heavy lifting, and findContains() is stolen from Aaron. It's very rough, currently just accepts !thunderwave --charid @{selected|character_id} (or you can specify the charId, of course), and has some ugly sendChat logging. There's no saves or spell involved yet, I was just playing with detecting the right tokens and sending our custom array through to Kaboom to make sure it would work. So currently, the 'lightning-helix' marker means 'failed save', and the target is pushed. There's a crude DL line to the south of the Archmage just to make sure I didn't break Kaboom's DL detection. Click for animation:

The code is this:
const thunderwaveKaboom = (() => { // eslint-disable-line no-unused-vars
const pixelsPerFoot = 14;
const clog = (txt) => {sendChat('thunderBot', txt, null, {noarchive: true})}
const findContains = (obj,filter,layer, spellRadius) => {
if(obj) {
let cx = obj.get('left'),
cy = obj.get('top');
filter = filter || (() => true);
layer = layer || 'objects';
return findObjs({
_pageid: obj.get('pageid'),
_type: "graphic",
subtype: "token",
layer: layer
})
.filter(filter)
.reduce((m,o) => {
let aura = spellRadius*pixelsPerFoot;
let l=o.get('left');
let t=o.get('top');
let w=parseInt(o.get('width')) + 2*aura;
let h=parseInt(o.get('height')) + 2*aura;
let ol=l-(w/2);
let or=l+(w/2);
let ot=t-(h/2);
let ob=t+(h/2);
if( ol <= cx && cx <= or
&& ot <= cy && cy <= ob
){
m.push(o);
}
return m;
},[]);
}
return [];
};
const handleInput = (msg) => { // !thunderwave --charid @{selected|character_id}
if (msg.type === "api" && msg.content.match(/^!thunderwave/i)) {
let charId = (msg.content.match(/--charid\s*(-[^\s]*)/i)) ? msg.content.match(/--charid\s*(-[^\s]*)/i)[1] : null;
if (charId && getObj('character', charId)) {
let casterToken = findObjs({type: 'graphic', subtype: 'token', represents: charId});
casterToken = (casterToken.length > 0) ? casterToken[0] : null;
if (!casterToken) return clog(`No token found for ${getObj('character', charId).get('name')}`)
let targetArray = findContains(casterToken, (o) => (o.id !== casterToken.id && o.get('represents')), 'objects', 5)
.filter((o) => o.get('statusmarkers').search(/lightning/i) !== -1);
// ^^^ saving throws go here instead of the filter, mapping the failed saves to a new array
let targetNames = targetArray.map((o) => o.get('name')).join(',');
clog(`Targets affected: ${targetNames}`);
KABOOM.NOW({effectPower: 10, effectRadius: 10, vfx: false, scatter: false}, casterToken, targetArray);
} else return clog(`Cannot find character for id: ${charId}`);
}
};
const registerEventHandlers = () => {
on('chat:message', handleInput);
};
on('ready', () => {
//checkInstall();
log('<< thunderWave test >> v0.0.0')
registerEventHandlers();
});
return {
// Public interface here
};
})();
And the minor modification to Kaboom (which does mean disabling the one-click version), lines 460 & 482 (or thereabouts):
const prepareExplosion = function (rawOptions, rawCenter, targetObjects) {
// Check if our inputs are valid
var options = verifyOptions(rawOptions);
var explosion_center = verifyObject(rawCenter);
var pageInfo = getPageInfo(explosion_center.pageid);
// Error checking for API users
if (!options.effectPower) {
log('KABOOM - Effect power missing.');
return false;
} else if (!explosion_center.position) {
log('KABOOM - Explosion center missing.');
return false;
} else if (options.effectPower > options.effectRadius) {
log('KABOOM - Effect radius must always be higher than effect power.');
return false;
} else if (!pageInfo) {
log('KABOOM - Pageid supplied does not exist.');
return false;
}
// findObjs arrays here
var affectedObjects = (targetObjects) ? targetObjects : findGraphics(explosion_center);
var walls = state.KABOOM.walls_stop_movement
I've added an extra argument for the public-facing function. If it isn't provided, Kaboom defaults to its normal findGraphics function. If it is provided, it skips the findGraphics bit and only processes the tokens we feed it. There's the one modification at the top: targetObjects, and the replaced definition for affectedObjects.
So... I'm not familiar with Group Check or Apply Damage. The easiest way might be to cherry-pick a couple of required functions and throw them in the custom script... I'm not sure. I'd assume trying to chain together too many scripts is going to involve some async issues... which could probably be fixed with Promises. That's something I'm a total novice on though :/
Let me know what you think. One of the cleverer people may have a much simpler solution though :)
edit - one minor issue: Kaboom uses Euclidean distance for diagonal pushes. I've not really dealt with token positions enough to know a good way around that (can't be too hard though).