1#!/usr/bin/env node
Jev's art critic, per revision: scores every revision of every song with
the art critic's rubric (website/src/jev/critic.mjs) and writes the result
into the song's SPEC.md frontmatter, as art: on the revision and as
critic: for the song as it stands.
op-env-run -- node tools/critic/critic.mjs # dry run: what it would score op-env-run -- node tools/critic/critic.mjs --write # score and record … --all rescore revisions that already have a score … --song jev/dial-up only this song (repeatable) … --runs 5 ask each revision 5 times (default 3)
Each revision is asked --runs times and the mean recorded: one call's
art moves by up to 0.06 on unchanged code (measured 2026-09-25 across 14
songs), enough to reorder the song list. art: is the mean, artRuns:
every run's own art beside it, and the critic: block's criteria are
means too (meanVerdict in website/src/jev/critic.mjs).
Each revision is judged on its own code against the spec it was made from:
- a revision whose
outputnames a file in the song folder (song.js, glitch.js) is that file, judged against the current spec; - an overwritten one (
output: null) is recovered from git: song.js and SPEC.md as they were just before the commit that recorded the next revision. If that snapshot is not the revision (no song.js, or a spec listing some other revision last), the revision is recorded asart: nullwith the reason, never guessed.
The current revision is also judged on its measured mix, when its spec
has a measured: taken of this very song.js (tools/measure): craft then
rests on how the parts actually sit, not only on the code.
The key is JEVSTRUDEL_TYPESAFE_API_KEY from the environment, which op-env-run supplies; it is never printed or written anywhere.
35import { execFileSync } from 'node:child_process'; 36import { readFileSync, readdirSync, existsSync, writeFileSync } from 'node:fs'; 37import { join } from 'node:path'; 38import { 39 artOf, 40 artRequest, 41 CRITIC_MODEL, 42 meanVerdict, 43 RUBRIC, 44 setCritic, 45 setRevisionArt, 46 specBody, 47} from '../../website/src/jev/critic.mjs'; 48import { measuredFor, readMeasured } from '../../website/src/jev/measured.mjs';
50const API = 'https://api.typesafe.ai/v1/systemone'; 51const args = process.argv.slice(2); 52const write = args.includes('--write'); 53const all = args.includes('--all'); 54const only = args.flatMap((a, i) => (args[i - 1] === '--song' ? [a] : [])); 55const runsArg = args.includes('--runs') ? args[args.indexOf('--runs') + 1] : '3'; 56const runs = Number(runsArg); 57if (!Number.isInteger(runs) || runs < 1) { 58 console.error(`critic: --runs takes a whole number of runs, 1 or more, not ${runsArg}`); 59 process.exit(1); 60} 61 62const root = execFileSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8' }).trim(); 63const git = (...a) => 64 execFileSync('git', ['-C', root, ...a], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }); 65const gitShow = (commit, path) => { 66 try { 67 return git('show', `${commit}:${path}`); 68 } catch { 69 return null; 70 } 71};
The frontmatter and the body, split as the first critic did
(text.split('---', 2)), so scores stay comparable with it.
Revisions from the frontmatter text: rev, output, and the existing art.
80function revisionsOf(fm) { 81 const block = fm.split(/^revisions:\s*$/m)[1]; 82 if (!block) return []; 83 return block 84 .split(/^ {2}- (?=rev:)/m) 85 .slice(1) 86 .map((entry) => ({ 87 rev: Number(entry.match(/^rev:\s*(\d+)/)[1]), 88 output: entry.match(/^ {4}output:\s*([^\s#]+)/m)?.[1], 89 art: entry.match(/^ {4}art:\s*([^\s#]+)/m)?.[1], 90 })); 91}
Where revision rev of an overwritten song.js lives in git.
97function recover(dir, rev) { 98 const rel = `songs/${dir}`; 99 const commits = git('log', '--format=%H', '--', `${rel}/SPEC.md`).split('\n').filter(Boolean).reverse(); 100 const next = commits.find((c) => { 101 const spec = gitShow(c, `${rel}/SPEC.md`); 102 return spec && lastRev(split(spec).fm) > rev; 103 }); 104 if (!next) return { reason: `no commit records a revision after rev ${rev}` }; 105 const before = `${next}^`; 106 const spec = gitShow(before, `${rel}/SPEC.md`); 107 const code = gitShow(before, `${rel}/song.js`); 108 if (!spec || !code) return { reason: 'predates this repo: no song.js before the next revision was recorded' }; 109 const listed = lastRev(split(spec).fm); 110 if (listed !== rev && !(listed === 0 && rev === 1)) { 111 return { reason: `the song.js before rev ${rev + 1} was recorded is rev ${listed}'s, not rev ${rev}'s` }; 112 } 113 return { code, body: split(spec).body, from: git('rev-parse', '--short', before).trim() }; 114}
116async function ask(state) { 117 const res = await fetch(API, { 118 method: 'POST', 119 headers: { 120 Authorization: `Bearer ${process.env.JEVSTRUDEL_TYPESAFE_API_KEY}`, 121 'Content-Type': 'application/json', 122 'User-Agent': 'jevstrudel-critic', 123 }, 124 body: JSON.stringify(artRequest(state)), 125 }); 126 if (!res.ok) throw new Error(`TypeSafe answered ${res.status}: ${(await res.text()).slice(0, 200)}`); 127 const verdict = artOf((await res.json()).answers); 128 if (!verdict) throw new Error('the answer is missing a criterion'); 129 return verdict; 130} 131 132const today = new Date().toLocaleDateString('en-CA'); // YYYY-MM-DD, local 133const dirs = readdirSync(join(root, 'songs'), { withFileTypes: true }) 134 .filter((d) => d.isDirectory()) 135 .flatMap((theme) => 136 readdirSync(join(root, 'songs', theme.name), { withFileTypes: true }) 137 .filter((d) => d.isDirectory() && existsSync(join(root, 'songs', theme.name, d.name, 'SPEC.md'))) 138 .map((d) => `${theme.name}/${d.name}`), 139 ) 140 .filter((d) => !only.length || only.includes(d)) 141 .sort(); 142 143if (write && !process.env.JEVSTRUDEL_TYPESAFE_API_KEY) { 144 console.error('critic: no JEVSTRUDEL_TYPESAFE_API_KEY; run inside op-env-run'); 145 process.exit(1); 146} 147 148let calls = 0; 149for (const dir of dirs) { 150 const path = join(root, 'songs', dir, 'SPEC.md'); 151 let text = readFileSync(path, 'utf8'); 152 const { fm, body } = split(text); 153 const title = titleOf(fm); 154 const song = fm.match(/^song:\s*(\S+)/m)?.[1] ?? 'song.js'; 155 let current = null; 156 for (const r of revisionsOf(fm)) { 157 const isCurrent = r.output === song; 158 if (r.art !== undefined && !all) { 159 console.log(`${dir} rev ${r.rev}: already scored (${r.art})`); 160 continue; 161 } 162 let job; 163 if (r.output && r.output !== 'null') { 164 const file = join(root, 'songs', dir, r.output); 165 job = existsSync(file) 166 ? { code: readFileSync(file, 'utf8'), body, from: r.output } 167 : { reason: `${r.output} is missing` }; 168 } else { 169 job = recover(dir, r.rev); 170 } 171 if (job.reason) { 172 console.log(`${dir} rev ${r.rev}: cannot score — ${job.reason}`); 173 if (write) text = setRevisionArt(text, r.rev, { reason: job.reason }); 174 continue; 175 } 176 if (!write) { 177 console.log(`${dir} rev ${r.rev}: would score ${job.from}${isCurrent ? ' (current)' : ''}, ${runs}×`); 178 continue; 179 } 180 // the measured mix (tools/measure) is of the current song.js only 181 const measured = isCurrent ? measuredFor(readMeasured(text), job.code) : null; 182 const state = { title, spec: job.body, code: job.code, measured }; 183 const verdict = meanVerdict(await Promise.all(Array.from({ length: runs }, () => ask(state)))); 184 calls += runs; 185 console.log( 186 `${dir} rev ${r.rev}: ${verdict.art} from [${verdict.artRuns.join(', ')}] (${job.from}${isCurrent ? ', current' : ''})`, 187 ); 188 text = setRevisionArt(text, r.rev, verdict); 189 if (isCurrent) current = verdict; 190 } 191 if (write && current !== null) text = setCritic(text, current, today); 192 if (write) writeFileSync(path, text); 193} 194if (write) console.log(`critic: ${calls} calls to ${CRITIC_MODEL}`);