Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
5fa6f13
fix(grading-display): fix copy-card accuracy + restore 3D highway hit…
ChrisBeWithYou Jul 10, 2026
6f60d70
docs(roadmap): log v0.1.10 grading-display fixes + correct stale vers…
ChrisBeWithYou Jul 10, 2026
d1f1157
feat(ui): host-parity topbar — Tuner/Instrument/Profile badges + host…
ChrisBeWithYou Jul 10, 2026
143a391
fix(generators): string-count-aware system defaults + bass shape guar…
ChrisBeWithYou Jul 10, 2026
9e234f6
feat(ui): 2D Highway joins the view lineup; view pills become a dropd…
ChrisBeWithYou Jul 10, 2026
af8fed8
feat(ui): highway_2d slot borrows the host Classic 2D Highway when av…
ChrisBeWithYou Jul 10, 2026
a5d04ec
test(smoke): seed the L1 instrument store per suite (host-sync contam…
ChrisBeWithYou Jul 12, 2026
4ee1f3a
fix(generators): interval-aware power-chord grip — the drop-tuning ba…
ChrisBeWithYou Jul 12, 2026
40cb71d
feat(generators): bass low-fifth reach — rfoPattern 'low5' (v0.1.15)
ChrisBeWithYou Jul 12, 2026
3a4efe9
feat(render): Classic 2D Highway parity — perspective falling-fret re…
ChrisBeWithYou Jul 13, 2026
cb6cc0d
feat(ladder): add guitar + bass tapping ladders (concept_tapping pack…
ChrisBeWithYou Jul 14, 2026
13a9c8f
docs(roadmap): mark tapping ladders shipped to dev + folded into PR #4
ChrisBeWithYou Jul 14, 2026
190fa5e
feat(coach): prescriptive coach loop on the results modal (v0.1.17)
ChrisBeWithYou Jul 14, 2026
b7211f5
feat(coach): bass Pocket-Diagnosis under the felt verdict (v0.1.18)
ChrisBeWithYou Jul 14, 2026
d68121a
feat(coach): localize the timing diagnosis to the worst bars (v0.1.19)
ChrisBeWithYou Jul 14, 2026
df2eabc
feat(coach): anti-thrash hysteresis — keep the player on one fix (v0.…
ChrisBeWithYou Jul 14, 2026
b22c15b
feat(ear-mode): "Hear it" — audition the target phrase (2.1, v0.1.21)
ChrisBeWithYou Jul 14, 2026
161801c
merge: reconcile main (Byron's Gold-rung 0.2.0, #11) into dev → 0.2.1
ChrisBeWithYou Jul 14, 2026
c022065
merge: land topbar stack #5-#10 (v0.1.11-0.1.16) onto dev (0.2.1)
ChrisBeWithYou Jul 14, 2026
8cc4076
merge: land coach loop #12 (v0.1.17-0.1.20) onto dev (0.2.1)
ChrisBeWithYou Jul 14, 2026
4f9bf8e
merge: land ear-mode 2.1 #13 (v0.1.21) onto dev; batch → v0.2.2
ChrisBeWithYou Jul 14, 2026
f0c78c6
feat(ui): read exact tuningMidis from GET /api/tunings (host #829)
ChrisBeWithYou Jul 12, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 19 additions & 5 deletions .claude/skills/run-virtuoso/driver.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ import { fileURLToPath } from "node:url";
const HOST = process.env.SLOPSMITH_HOST || "http://127.0.0.1:8765";
const SHOT_DIR = process.env.SHOT_DIR || resolve(dirname(fileURLToPath(import.meta.url)), "../../../.virtuoso-shots");

const RENDERERS = ["highway_3d", "builtin_2d", "tab_2d", "notation_2d"];
const RENDERERS = ["highway_3d", "highway_2d", "builtin_2d", "tab_2d", "notation_2d"];

async function ensureHost() {
const r = await fetch(`${HOST}/api/plugins/virtuoso/status`).catch(() => null);
Expand Down Expand Up @@ -64,7 +64,7 @@ async function gotoVirtuoso(page) {
await page.waitForSelector("#virtuoso-root", { state: "attached", timeout: 10_000 });
// The bootstrap script in screen.html runs bind() on DOMContentLoaded; give
// it a moment to wire up everything (pathway dropdown, view-switcher).
await page.waitForSelector(".virtuoso-view-btn", { timeout: 5_000 });
await page.waitForSelector("#virtuoso-view-select", { timeout: 5_000 });
}

async function generate(page) {
Expand Down Expand Up @@ -93,9 +93,16 @@ async function generate(page) {
}

async function switchRenderer(page, kind) {
const btn = await page.$(`.virtuoso-view-btn[data-renderer="${kind}"]`);
if (!btn) throw new Error(`Renderer button not found: ${kind}`);
await btn.click();
// View selector is a dropdown (v0.1.13): set the value + dispatch change,
// the same event path a user pick takes (change → onViewSwitch).
const ok = await page.evaluate((k) => {
const sel = document.querySelector("#virtuoso-view-select");
if (!sel || ![...sel.options].some((o) => o.value === k)) return false;
sel.value = k;
sel.dispatchEvent(new Event("change", { bubbles: true }));
return true;
}, kind);
if (!ok) throw new Error(`Renderer option not found: ${kind}`);
await page.waitForTimeout(600); // attachRenderer is async; let it settle
}

Expand All @@ -111,6 +118,13 @@ async function withBrowser(fn) {
try {
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
const page = await ctx.newPage();
// Seed the L1 instrument store BEFORE page scripts run: the host-settings
// sync (v0.1.11) treats an empty localStorage as a fresh install and ADOPTS
// host config — which persists whatever instrument the PREVIOUS suite's panel
// drives wrote through (cross-suite contamination; the panel flipped to bass
// mid-suite). With the store seeded, the local-wins boot path holds the
// deterministic 6-string default AND heals the host config for later suites.
await page.addInitScript(() => { try { localStorage.setItem("virtuoso.instrument", JSON.stringify({ stringSetup: "guitar_6_standard", customOpenMidis: "" })); } catch (_) {} });
page.on("pageerror", (e) => console.error("[page error]", e.message));
page.on("console", (m) => {
const t = m.type();
Expand Down
3 changes: 2 additions & 1 deletion .claude/skills/run-virtuoso/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,9 @@
"smoke:contained": "node smoke-contained-verifier.mjs",
"smoke:level-gate": "node smoke-level-gate-async.mjs",
"smoke:host-surface": "node smoke-host-surface.mjs",
"smoke:coach": "node smoke-coach-rx.mjs",
"test": "node run-all.mjs",
"test:seq": "node smoke-renderers.mjs && node smoke-generators.mjs && node smoke-highway-settings.mjs && node smoke-strings.mjs && node smoke-meter-subdiv.mjs && node smoke-over-barline.mjs && node smoke-herta.mjs && node smoke-audioctx.mjs && node smoke-connect.mjs && node smoke-variation.mjs && node smoke-session-sync.mjs && node smoke-progress.mjs && node smoke-core-purity.mjs && node smoke-gems.mjs && node smoke-scoring-e2e.mjs && node smoke-backing-engine.mjs && node smoke-contained-verifier.mjs && node smoke-level-gate-async.mjs && node smoke-host-surface.mjs"
"test:seq": "node smoke-renderers.mjs && node smoke-generators.mjs && node smoke-highway-settings.mjs && node smoke-strings.mjs && node smoke-meter-subdiv.mjs && node smoke-over-barline.mjs && node smoke-herta.mjs && node smoke-audioctx.mjs && node smoke-connect.mjs && node smoke-variation.mjs && node smoke-session-sync.mjs && node smoke-progress.mjs && node smoke-core-purity.mjs && node smoke-gems.mjs && node smoke-scoring-e2e.mjs && node smoke-backing-engine.mjs && node smoke-contained-verifier.mjs && node smoke-level-gate-async.mjs && node smoke-host-surface.mjs && node smoke-coach-rx.mjs"
},
"keywords": [],
"author": "",
Expand Down
7 changes: 7 additions & 0 deletions .claude/skills/run-virtuoso/smoke-audioctx.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,13 @@ try {
const ctx = await browser.newContext({ viewport: { width: 1440, height: 900 } });
await ctx.addInitScript(() => { globalThis.__SS_HARNESS__ = true; }); // sink rows read __ss_debug
const page = await ctx.newPage();
// Seed the L1 instrument store BEFORE page scripts run: the host-settings
// sync (v0.1.11) treats an empty localStorage as a fresh install and ADOPTS
// host config — which persists whatever instrument the PREVIOUS suite's panel
// drives wrote through (cross-suite contamination; the panel flipped to bass
// mid-suite). With the store seeded, the local-wins boot path holds the
// deterministic 6-string default AND heals the host config for later suites.
await page.addInitScript(() => { try { localStorage.setItem("virtuoso.instrument", JSON.stringify({ stringSetup: "guitar_6_standard", customOpenMidis: "" })); } catch (_) {} });
page.on("pageerror", e => console.error("[page error]", e.message));
await page.goto(`${HOST}/`, { waitUntil: "domcontentloaded" });
await page.waitForSelector("#plugin-virtuoso", { state: "attached", timeout: 20000 });
Expand Down
9 changes: 8 additions & 1 deletion .claude/skills/run-virtuoso/smoke-backing-engine.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,13 @@ try {
try { new PerformanceObserver((l) => l.getEntries().forEach((e) => window.__longTasks.push(Math.round(e.duration)))).observe({ entryTypes: ["longtask"] }); } catch {}
});
const page = await ctx.newPage();
// Seed the L1 instrument store BEFORE page scripts run: the host-settings
// sync (v0.1.11) treats an empty localStorage as a fresh install and ADOPTS
// host config — which persists whatever instrument the PREVIOUS suite's panel
// drives wrote through (cross-suite contamination; the panel flipped to bass
// mid-suite). With the store seeded, the local-wins boot path holds the
// deterministic 6-string default AND heals the host config for later suites.
await page.addInitScript(() => { try { localStorage.setItem("virtuoso.instrument", JSON.stringify({ stringSetup: "guitar_6_standard", customOpenMidis: "" })); } catch (_) {} });
const errs = []; page.on("pageerror", (e) => errs.push(e.message));
await page.goto(`${HOST}/`, { waitUntil: "domcontentloaded" });
await page.waitForSelector("#plugin-virtuoso", { state: "attached", timeout: 20000 });
Expand All @@ -57,7 +64,7 @@ try {
catch (e) { if (i >= 2) throw e; await page.waitForTimeout(1500); }
}
await page.waitForSelector("#virtuoso-root", { state: "attached" });
await page.waitForSelector(".virtuoso-view-btn");
await page.waitForSelector("#virtuoso-view-select");
await page.waitForFunction(() => window.Virtuoso && typeof window.Virtuoso.generateExercise === "function" && globalThis.__ss_debug);
await page.waitForTimeout(600); // let the screen's boot settle (first-load race)

Expand Down
146 changes: 146 additions & 0 deletions .claude/skills/run-virtuoso/smoke-coach-rx.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
#!/usr/bin/env node
// Regression guard for the prescriptive COACH LOOP (PR 1.3; npm run smoke:coach,
// in npm test). The results modal turns the already-computed diagnostic data
// (heat.fgMiss / transMiss, leanMs, nearMiss, the bass felt verdict) into ONE
// prescriptive next-drill. This suite unit-tests the PURE fault→drill mapping via
// the window.__virtuosoCoach.coachRxFor hook — it takes a fault FIXTURE directly,
// so it is immune to the "smoke mocks the verifier" blind spot (no real detector
// needed). It also asserts every prescribed pathway id is a real #virtuoso-pathway
// <option> so the load button can never dead-end.
//
// The mapping was validated 2026-07-14 by guitar-pedagogy, bass-pedagogy, and
// learning-design agents (see the coachRxFor comments in screen.js). (host up via
// launch.ps1.)
import { chromium } from "playwright";
const HOST = process.env.SLOPSMITH_HOST || "http://127.0.0.1:8765";
let fails = 0;
const ok = (c, l, d) => { console.log(` [${c ? "PASS" : "FAIL"}] ${l}${d ? " " + d : ""}`); if (!c) fails++; };

const b = await chromium.launch({ headless: true });
try {
const r = await fetch(`${HOST}/api/plugins/virtuoso/status`).catch(() => null);
if (!r || !r.ok) throw new Error(`Host not reachable at ${HOST}. launch.ps1 first.`);
const page = await (await b.newContext({ viewport: { width: 1440, height: 900 } })).newPage();
const pageErrs = []; page.on("pageerror", e => pageErrs.push(e.message));
await page.goto(`${HOST}/`, { waitUntil: "domcontentloaded" });
await page.waitForSelector("#plugin-virtuoso", { state: "attached", timeout: 20000 });
await page.waitForFunction(() => typeof window.showScreen === "function");
await page.evaluate(() => window.showScreen("plugin-virtuoso"));
await page.waitForSelector("#virtuoso-root", { state: "attached" });
await page.waitForFunction(() => window.__virtuosoCoach && typeof window.__virtuosoCoach.coachRxFor === "function", { timeout: 10000 });

// The fault fixtures → expected prescription. Each `info` mirrors the shape the
// scorer stashes on _ptRunInfo (heat/leanMs/leanN/nearMiss); felt is the bass
// felt-hold verdict. curPw = the rung just run (drives the never-repeat-it rule).
const GTR = false, BASS = true;
const heat = (o) => Object.assign({ missTotal: 0, fgMiss: {}, transMiss: {}, opens: [] }, o);
const cases = [
// guitar — a dropped fretting finger → the chromatic warm-up; pinky named.
{ n: "gtr pinky finger", ctx: { isBass: GTR, curPw: "pent_foundation", info: { heat: heat({ missTotal: 5, fgMiss: { 4: 4 } }) } }, id: "chromatic_warmup", why: /pinky/ },
// guitar — same fault but they JUST ran chromatic_warmup → sibling spider.
{ n: "gtr finger, avoid repeat", ctx: { isBass: GTR, curPw: "chromatic_warmup", info: { heat: heat({ missTotal: 5, fgMiss: { 4: 4 } }) } }, id: "fs_spider_adjacent" },
// guitar — a fumbled adjacent crossing → alternate picking (NOT string-skip).
{ n: "gtr string crossing", ctx: { isBass: GTR, curPw: "pent_foundation", info: { heat: heat({ missTotal: 4, fgMiss: { 1: 1 }, transMiss: { "0>1": 4 }, opens: [40, 45] }) } }, id: "pick_alternate", why: /E→A/ },
// guitar — a timing lean ahead of the click → the single-string pulse.
{ n: "gtr rush", ctx: { isBass: GTR, curPw: "pent_foundation", info: { heat: null, leanN: 20, leanMs: -45, nearMiss: 0 } }, id: "rhy_single_string", why: /ahead/ },
// guitar — timing SCATTER (near-miss) → the subdivision grid.
{ n: "gtr scatter", ctx: { isBass: GTR, curPw: "pent_foundation", info: { heat: null, leanN: 5, leanMs: 5, nearMiss: 6 } }, id: "rhy_subdivision" },
// PR 1.4 — a lean that LOCALIZES to a hot region names the bars…
{ n: "gtr drag localized", ctx: { isBass: GTR, curPw: "pent_foundation", info: { heat: null, leanN: 20, leanMs: 40, nearMiss: 0,
leanBars: { bars: [{ i: 0, lean: 5, n: 4 }, { i: 1, lean: 8, n: 4 }, { i: 2, lean: 10, n: 4 }, { i: 3, lean: 12, n: 4 }, { i: 4, lean: 60, n: 4 }, { i: 5, lean: 58, n: 4 }, { i: 6, lean: 9, n: 4 }, { i: 7, lean: 7, n: 4 }] } } }, id: "rhy_single_string", why: /dragged most in bars 5–6/ },
// …but a WHOLE-RUN lean stays generic (not "bars 1–8").
{ n: "gtr drag global", ctx: { isBass: GTR, curPw: "pent_foundation", info: { heat: null, leanN: 20, leanMs: 40, nearMiss: 0,
leanBars: { bars: [{ i: 0, lean: 38, n: 4 }, { i: 1, lean: 42, n: 4 }, { i: 2, lean: 40, n: 4 }, { i: 3, lean: 41, n: 4 }, { i: 4, lean: 39, n: 4 }, { i: 5, lean: 43, n: 4 }] } } }, id: "rhy_single_string", why: /behind the click/ },
// guitar — never send a timing-faulted rhy_single_string run back to itself.
{ n: "gtr rush, avoid repeat", ctx: { isBass: GTR, curPw: "rhy_single_string", info: { heat: null, leanN: 20, leanMs: -45, nearMiss: 0 } }, id: "rhy_subdivision" },
// bass — a weak pinky routes to the hammer-on/pull-off strengthener (copy too).
{ n: "bass pinky", ctx: { isBass: BASS, curPw: "bass_scale_one_box", info: { heat: heat({ missTotal: 5, fgMiss: { 4: 4 } }) } }, id: "bass_finger_legato", why: /pinky.*hammer-ons/ },
// bass — any other finger → the fretting-hand Finger Gym, one clean finger/fret.
{ n: "bass middle finger", ctx: { isBass: BASS, curPw: "bass_scale_one_box", info: { heat: heat({ missTotal: 5, fgMiss: { 2: 4 } }) } }, id: "bass_finger_gym", why: /middle.*one clean finger/ },
// bass — the felt DRAGGING verdict → the above-floor i–m motor (root_click's
// sub-70 Hz open roots are the sibling fallback only).
{ n: "bass felt drag", ctx: { isBass: BASS, curPw: "bass_scale_one_box", info: { heat: null, leanN: 0, nearMiss: 0 }, felt: { verdict: "dragging" } }, id: "bass_rh_pulse", why: /behind/ },
{ n: "bass drag, avoid repeat", ctx: { isBass: BASS, curPw: "bass_rh_pulse", info: { heat: null, leanN: 0, nearMiss: 0 }, felt: { verdict: "dragging" } }, id: "bass_root_click" },
// a clean run (nothing crosses threshold) → no nag.
{ n: "clean run → null", ctx: { isBass: GTR, curPw: "pent_foundation", info: { heat: null, leanN: 5, leanMs: 5, nearMiss: 0 } }, id: null },
];

const results = await page.evaluate((cs) => cs.map(c => {
const rx = window.__virtuosoCoach.coachRxFor(c.ctx);
return { n: c.n, pathwayId: rx ? rx.pathwayId : null, why: rx ? rx.why : "" };
}), cases);

const prescribed = new Set();
for (let i = 0; i < cases.length; i++) {
const c = cases[i], got = results[i];
ok(got.pathwayId === c.id, c.n, `→ ${got.pathwayId} (want ${c.id})`);
if (c.why) ok(c.why.test(got.why), `${c.n}: copy`, `“${got.why}”`);
if (got.pathwayId) prescribed.add(got.pathwayId);
}

// Every prescription the mapping can EVER emit must be a real pathway option, or
// the load button dead-ends. Assert the full target set (not just the fired ones).
const targets = ["chromatic_warmup", "fs_spider_adjacent", "pick_alternate", "pick_economy",
"rhy_single_string", "rhy_subdivision", "bass_finger_legato", "bass_finger_gym",
"bass_rh_crossing", "bass_rh_pulse", "bass_root_click"];
const opts = await page.evaluate(() => Array.from(document.querySelectorAll('#virtuoso-pathway option')).map(o => o.value));
for (const t of targets) ok(opts.includes(t), `target is a real pathway option: ${t}`);

// ── PR 1.5 · Bass Pocket-Diagnosis — the felt { verdict, leanMs, driftMs,
// jitterMs, untight } → the SPECIFIC pocket sentence (drift → lean → jitter,
// matching feltHoldAnalyze's own verdict bucketing).
const pocket = [
{ n: "locked → good", felt: { verdict: "locked", leanMs: 5, driftMs: 5, jitterMs: 10 }, tone: "good", text: /Dead in the pocket/ },
{ n: "drift late", felt: { verdict: "dragging", leanMs: 10, driftMs: 50, jitterMs: 20 }, tone: "work", text: /slips later.*slowing down.*keep the subdivision moving/ },
{ n: "drift early", felt: { verdict: "rushing", leanMs: -5, driftMs: -50, jitterMs: 20 }, tone: "work", text: /creeps ahead.*speeding up.*don't chase it/ },
{ n: "drift wins over lean", felt: { verdict: "dragging", leanMs: 40, driftMs: 50, jitterMs: 20 }, tone: "work", text: /across the phrase/ },
{ n: "lean behind", felt: { verdict: "dragging", leanMs: 35, driftMs: 5, jitterMs: 20 }, tone: "work", text: /behind the beat.*Lean in a hair earlier/ },
{ n: "lean ahead", felt: { verdict: "rushing", leanMs: -25, driftMs: 5, jitterMs: 20 }, tone: "work", text: /ahead of the beat.*Relax/ },
{ n: "settling jitter", felt: { verdict: "settling", leanMs: 5, driftMs: 5, jitterMs: 38 }, tone: "work", text: /uneven.*not locked yet/ },
{ n: "untight", felt: { verdict: null, untight: true, leanMs: 5, driftMs: 5, jitterMs: 60 }, tone: "work", text: /uneven/ },
{ n: "low evidence → null", felt: { verdict: null, untight: false, leanMs: 5, driftMs: 5, jitterMs: 10 }, tone: null, text: null },
{ n: "no felt → null", felt: null, tone: null, text: null },
];
const pres = await page.evaluate((ps) => ps.map(p => {
const d = window.__virtuosoCoach.buildPocketDiagnosis(p.felt);
return { n: p.n, tone: d ? d.tone : null, text: d ? d.text : "" };
}), pocket);
for (let i = 0; i < pocket.length; i++) {
const p = pocket[i], got = pres[i];
ok(got.tone === p.tone, `pocket: ${p.n}`, `tone=${got.tone} (want ${p.tone})`);
if (p.text) ok(p.text.test(got.text), `pocket: ${p.n} copy`, `“${got.text}”`);
else ok(got.text === "", `pocket: ${p.n} suppressed`);
}

// ── anti-thrash hysteresis — keep the player on ONE fix across runs. Given a
// ranked candidate list + last run's focus { cat, streak }, decide which
// fault to prescribe now and the focus to persist.
const F = { fault: "finger", pathwayId: "chromatic_warmup", why: "f" };
const T = { fault: "timing", pathwayId: "rhy_single_string", why: "t" };
const hyst = [
// last run said finger; timing now tops but finger still trips → REINFORCE finger.
{ n: "reinforce prior focus", cands: [T, F], prev: { cat: "finger", streak: 0 }, chosen: "finger", streak: 1 },
// …but not forever: after 2 reinforcements, let the new top take over.
{ n: "release after 2 holds", cands: [T, F], prev: { cat: "finger", streak: 2 }, chosen: "timing", streak: 0 },
// prior focus no longer trips a threshold → switch to the top.
{ n: "prior focus gone → top", cands: [T], prev: { cat: "finger", streak: 0 }, chosen: "timing", streak: 0 },
// no history → the top, fresh streak.
{ n: "no history → top", cands: [F, T], prev: null, chosen: "finger", streak: 0 },
// same fault two runs running → natural reinforcement, streak grows.
{ n: "same top → streak grows", cands: [T], prev: { cat: "timing", streak: 1 }, chosen: "timing", streak: 2 },
// nothing to prescribe → keep the prior focus untouched, no pick.
{ n: "no candidates → null", cands: [], prev: { cat: "timing", streak: 1 }, chosen: null, streak: 1 },
];
const hres = await page.evaluate((hs) => hs.map(h => {
const r = window.__virtuosoCoach.applyCoachHysteresis(h.cands, h.prev);
return { n: h.n, chosen: r.chosen ? r.chosen.fault : null, streak: r.focus ? r.focus.streak : null };
}), hyst);
for (let i = 0; i < hyst.length; i++) {
const h = hyst[i], got = hres[i];
ok(got.chosen === h.chosen && got.streak === h.streak, `hysteresis: ${h.n}`, `chosen=${got.chosen} streak=${got.streak}`);
}

ok(pageErrs.length === 0, "no uncaught page errors", pageErrs.join(" | "));
console.log(`\n${fails === 0 ? "PASS" : "FAIL"} coach prescription: ${fails} failure(s)`);
process.exit(fails ? 1 : 0);
} finally { await b.close(); }
Loading
Loading