@josueavalosjim/taste-check 0.6.0 → 0.8.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.
package/README.md CHANGED
@@ -290,6 +290,35 @@ inside a tool is somebody else's taste with the tool's authority behind it.
290
290
  Checklist lines are list items in your file; a heading or a paragraph is prose
291
291
  and is not judged.
292
292
 
293
+ ### Letting an agent carry the call
294
+
295
+ A shell command is one way to reach a model and a poor one for an agent, which
296
+ is already a model and can spawn a genuinely fresh context of its own instead
297
+ of shelling out to a second copy of itself. So the judge splits in two:
298
+
299
+ ```bash
300
+ taste-check judge --emit # the prompt, and nothing else happens
301
+ taste-check judge --verdict reply.json # or - for stdin
302
+ ```
303
+
304
+ `--emit` prints the prompt and stops. It still refuses when there are no
305
+ screenshots or no checklist, because a prompt for nothing gets a confident
306
+ verdict about nothing. `--verdict` checks the reply against the checklist
307
+ exactly as the shell route does: every line answered once, no invented lines,
308
+ valid verdicts. Taking the agent route does not buy a softer grading.
309
+
310
+ `judge.command` is optional when you use this. There is a skill for it:
311
+
312
+ ```bash
313
+ taste-check judge --skill # prints the path to SKILL.md
314
+ ```
315
+
316
+ Copy it wherever your agent keeps skills. It carries the mechanism, which is
317
+ that the agent must not be the judge: it is reading the session that built the
318
+ thing, and the reasoning that justified each choice is still sitting there
319
+ ready to justify it again. It carries no design rules, for the same reason
320
+ nothing else here does.
321
+
293
322
  ### What the exit code means here
294
323
 
295
324
  A verdict is an opinion, so a `fail` prints as a note and the command exits 0.
@@ -328,7 +357,7 @@ from anywhere.
328
357
  | `judge.checklist` | Your checklist file. List items are judged, prose is not. |
329
358
  | `judge.shots` | Screenshots to hand the judge. Matching nothing is a failure. |
330
359
  | `judge.shotCommand` | Optional command run first to produce those screenshots. |
331
- | `judge.command` | The model command. Prompt on stdin, image paths as arguments. |
360
+ | `judge.command` | The model command. Prompt on stdin, image paths as arguments. Optional if an agent carries the call. |
332
361
  | `judge.failOn` | `"never"` (default) or `"fail"`. Whether a verdict blocks. |
333
362
  | `runtime.url` | The page to measure. A `file://` URL works. |
334
363
  | `runtime.endpoint` | An existing CDP endpoint. Given one, taste-check connects rather than launching, and never closes a browser it did not start. |
@@ -428,10 +457,11 @@ component are all invisible here. What this gives you is that the
428
457
  values in your token file relate to each other the way you said they should. It
429
458
  does not prove what a visitor sees.
430
459
 
431
- **Only some colour formats parse.** Hex in 3, 4, 6 and 8 digits; `rgb()`,
432
- `rgba()`, `hsl()`, `hsla()`, `hwb()`, `oklch()` and `oklab()`; and `white` /
433
- `black` / `transparent`. `lab()`, `lch()` and `color-mix()` are not parsed yet.
434
- A value it cannot parse fails, so you hear about it immediately.
460
+ **Not every colour format parses.** What does: hex in 3, 4, 6 and 8 digits;
461
+ `rgb()`, `rgba()`, `hsl()`, `hsla()`, `hwb()`, `oklch()`, `oklab()`, `lab()`
462
+ and `lch()`; and `white` / `black` / `transparent`. `color-mix()` and
463
+ `color()` do not. A value it cannot parse fails, so you hear about it
464
+ immediately.
435
465
 
436
466
  An `oklch()` outside the sRGB gamut is clipped rather than gamut-mapped, which
437
467
  is what a browser canvas does with it. That was checked rather than assumed:
@@ -453,12 +483,9 @@ Not built. Written down so the shape is clear.
453
483
 
454
484
  **YAML configs**, once there is a reason to take on a parser.
455
485
 
456
- **A way to run the judge from an agent skill**, not only from a shell.
457
-
458
- **`lab()` and `lch()`**, which need the D50 white point and a chromatic
459
- adaptation step that `oklch()` does not. Completeness rather than reach, so
460
- it sits behind the others. Worth doing the same way when it happens: derive it,
461
- then check every case against a browser rather than trusting the matrices.
486
+ **`color-mix()` and `color()`**, which are a different shape of problem: one
487
+ needs an interpolation model and the other a colour space argument, so neither
488
+ is another conversion function.
462
489
 
463
490
  ## Development
464
491
 
@@ -8,9 +8,10 @@
8
8
  * not a quiet skip.
9
9
  */
10
10
  import { readFileSync } from 'node:fs';
11
+ import { fileURLToPath } from 'node:url';
11
12
 
12
13
  import { load } from '../src/config.mjs';
13
- import { judge, run, runtime } from '../src/index.mjs';
14
+ import { gradeVerdict, judge, prepareJudge, run, runtime } from '../src/index.mjs';
14
15
  import { failed, toJson, toText } from '../src/report.mjs';
15
16
  import { toSarif } from '../src/sarif.mjs';
16
17
 
@@ -23,6 +24,10 @@ const USAGE = `taste-check
23
24
  Options:
24
25
  -c, --config <path> Config file (default: tastecheck.config.json)
25
26
  --only <name> Run one check: contrast or treatments
27
+ --emit judge only: print the prompt and stop, for an agent
28
+ to carry to a model itself
29
+ --verdict <path> judge only: grade a reply from a file, or - for stdin
30
+ --skill Print the path to the bundled agent skill
26
31
  --format <kind> text (default), json, or sarif
27
32
  --json Alias for --format json
28
33
  -h, --help This
@@ -43,6 +48,11 @@ is already up. It measures what is actually painted, compositing every
43
48
  background layer behind an element rather than stopping at the first opaque
44
49
  one, and it can put the page into a state first.
45
50
 
51
+ An agent can carry the model call instead of a shell command. --emit prints
52
+ the prompt and the images; the agent asks a fresh context and pipes the JSON
53
+ back to --verdict -, which checks it against the checklist the same way. Run
54
+ taste-check judge --skill for the bundled skill that wires this up.
55
+
46
56
  The judge is a separate command because it runs a model, and a model's
47
57
  verdict is not reproducible. Its verdicts print as notes and do not affect
48
58
  the exit code unless judge.failOn is set to "fail". Whether the judge ran
@@ -50,7 +60,15 @@ at all is a different question: no screenshots, a command that failed, or
50
60
  a reply that skipped a checklist line all exit 1 either way.`;
51
61
 
52
62
  function parseArgs(argv) {
53
- const options = { config: 'tastecheck.config.json', only: null, format: 'text', command: 'check' };
63
+ const options = {
64
+ config: 'tastecheck.config.json',
65
+ only: null,
66
+ format: 'text',
67
+ command: 'check',
68
+ emit: false,
69
+ verdict: null,
70
+ skill: false,
71
+ };
54
72
  // One positional, and only in first position, so a stray argument is an
55
73
  // error rather than something silently ignored.
56
74
  if (argv[0] === 'judge' || argv[0] === 'runtime') {
@@ -61,7 +79,11 @@ function parseArgs(argv) {
61
79
  const arg = argv[i];
62
80
  const next = () => {
63
81
  const value = argv[i + 1];
64
- if (value === undefined || value.startsWith('-')) throw new Error(`${arg} needs a value`);
82
+ // A bare "-" is a value, not a flag: it is the conventional name for
83
+ // stdin and --verdict takes it.
84
+ if (value === undefined || (value !== '-' && value.startsWith('-'))) {
85
+ throw new Error(`${arg} needs a value`);
86
+ }
65
87
  i += 1;
66
88
  return value;
67
89
  };
@@ -74,13 +96,15 @@ function parseArgs(argv) {
74
96
  throw new Error(`--only takes "contrast" or "treatments", not "${options.only}"`);
75
97
  }
76
98
  } else if (arg === '--json') options.format = 'json';
99
+ else if (arg === '--emit') options.emit = true;
100
+ else if (arg === '--skill') options.skill = true;
101
+ else if (arg === '--verdict') options.verdict = next();
77
102
  else if (arg === '--format') {
78
103
  options.format = next();
79
104
  if (!['text', 'json', 'sarif'].includes(options.format)) {
80
105
  throw new Error(`--format takes text, json or sarif, not "${options.format}"`);
81
106
  }
82
- }
83
- else if (!arg.startsWith('-')) throw new Error(`unknown command "${arg}"`);
107
+ } else if (!arg.startsWith('-')) throw new Error(`unknown command "${arg}"`);
84
108
  else throw new Error(`unknown option "${arg}"`);
85
109
  }
86
110
  return options;
@@ -108,6 +132,19 @@ if (options.version) {
108
132
  process.exit(0);
109
133
  }
110
134
 
135
+ // Printing the bundled skill needs no config: it is the same file every time.
136
+ if (options.skill) {
137
+ console.log(fileURLToPath(new URL('../skills/taste-check-judge/SKILL.md', import.meta.url)));
138
+ process.exit(0);
139
+ }
140
+
141
+ for (const flag of ['emit', 'verdict']) {
142
+ if (options[flag] && options.command !== 'judge') {
143
+ die(`--${flag} applies to \`taste-check judge\`, not to ${options.command}`);
144
+ }
145
+ }
146
+ if (options.emit && options.verdict) die('--emit prints a prompt and --verdict grades a reply, so not both');
147
+
111
148
  const loaded = load(options.config);
112
149
  if (!loaded.ok) {
113
150
  console.error(`taste-check: ${options.config} could not be used:\n`);
@@ -123,8 +160,34 @@ if (options.command !== 'check' && !loaded.config[options.command]) {
123
160
  die(`${options.config} defines no "${options.command}" block.`);
124
161
  }
125
162
 
163
+ // --emit stops before any model is involved, so it has no findings to report
164
+ // and no exit code to earn. It still refuses to hand back a prompt when the
165
+ // screenshots or the checklist are missing.
166
+ if (options.emit) {
167
+ const prepared = prepareJudge(loaded.config.judge, loaded.dir);
168
+ if (!prepared.ok) {
169
+ console.log(toText([prepared.result]));
170
+ process.exit(1);
171
+ }
172
+ console.log(prepared.prompt);
173
+ process.exit(0);
174
+ }
175
+
126
176
  let results;
127
- if (options.command === 'judge') results = judge(loaded.config, loaded.dir);
177
+ if (options.verdict) {
178
+ const prepared = prepareJudge(loaded.config.judge, loaded.dir);
179
+ if (!prepared.ok) {
180
+ console.log(toText([prepared.result]));
181
+ process.exit(1);
182
+ }
183
+ let reply;
184
+ try {
185
+ reply = readFileSync(options.verdict === '-' ? 0 : options.verdict, 'utf8');
186
+ } catch {
187
+ die(`cannot read the verdict from ${options.verdict === '-' ? 'stdin' : options.verdict}`);
188
+ }
189
+ results = [gradeVerdict(reply, prepared, loaded.config.judge)];
190
+ } else if (options.command === 'judge') results = judge(loaded.config, loaded.dir);
128
191
  else if (options.command === 'runtime') results = await runtime(loaded.config, loaded.dir);
129
192
  else results = run(loaded.config, loaded.dir, { only: options.only });
130
193
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@josueavalosjim/taste-check",
3
- "version": "0.6.0",
3
+ "version": "0.8.0",
4
4
  "description": "Design review in CI with a line down the middle: measured checks that gate the build (WCAG contrast from your tokens or from a real rendered page, one-off values in your markup) and a fresh-eyes model judge whose verdicts stay advisory. Zero dependencies. Ships no design rules of its own.",
5
5
  "keywords": [
6
6
  "accessibility",
@@ -34,6 +34,7 @@
34
34
  "bin",
35
35
  "src",
36
36
  "schema",
37
+ "skills",
37
38
  "README.md",
38
39
  "LICENSE"
39
40
  ],
@@ -0,0 +1,82 @@
1
+ ---
2
+ name: taste-check-judge
3
+ description: Run the taste-check fresh-eyes judge on a design, using a fresh subagent as the judge rather than a shell command. Use after building or changing a screen, when asked to review or critique a design, or on any request to run the taste gate. Requires a tastecheck.config.json with a judge block.
4
+ ---
5
+
6
+ # taste-check judge
7
+
8
+ You are the transport, not the judge.
9
+
10
+ `taste-check` builds the prompt and checks the answer. You carry the prompt to a
11
+ model and carry the reply back. The model you carry it to must not be you.
12
+
13
+ ## Why not you
14
+
15
+ You are reading this inside a session that has context: what was built, what it
16
+ was for, which tradeoffs were made and why. That context is exactly what
17
+ disqualifies you from judging the result. The reasoning that justified each
18
+ choice is still sitting here ready to justify it again, and a review that
19
+ reaches for it is not a review.
20
+
21
+ So the judge is a separate agent with none of it. That is the whole mechanism,
22
+ and skipping it turns this into self-assessment with extra steps.
23
+
24
+ ## Steps
25
+
26
+ **1. Produce the screenshots.** However this project does it. If the config has
27
+ a `shotCommand`, the next step runs it for you.
28
+
29
+ **2. Get the prompt.**
30
+
31
+ ```
32
+ npx taste-check judge --emit
33
+ ```
34
+
35
+ It prints the prompt, which already contains the framing and the user's
36
+ checklist. It exits 1 and prints nothing usable if there are no screenshots or
37
+ no checklist, which is deliberate: a judge with nothing to look at must not
38
+ produce a verdict.
39
+
40
+ **3. Ask a fresh agent.** Spawn a new general-purpose agent on the strongest
41
+ reasoning tier available. Give it:
42
+
43
+ - the prompt from step 2, verbatim
44
+ - the screenshot files it names
45
+
46
+ Give it nothing else. No summary of what changed. No statement of intent. No
47
+ prior conversation. Do not mention which lines you expect to fail, do not say
48
+ what you already fixed, and do not add a checklist item of your own. Every one
49
+ of those turns the verdict into your opinion with a second signature on it.
50
+
51
+ Ask it to reply with the JSON the prompt specifies and nothing else.
52
+
53
+ **4. Grade the reply.**
54
+
55
+ ```
56
+ npx taste-check judge --verdict reply.json
57
+ ```
58
+
59
+ or pipe it on stdin with `-`. This checks the reply against the checklist:
60
+ every line answered exactly once, no invented lines, valid verdicts. A judge
61
+ that quietly drops the hardest line is the failure this catches.
62
+
63
+ Exit code 1 means the judge could not run, or a verdict blocked under
64
+ `failOn: "fail"`. Verdicts are otherwise advisory and print as notes.
65
+
66
+ ## Reporting back
67
+
68
+ Report what the judge said, including the passes. Do not soften a `fail` and do
69
+ not quietly drop an `unsure`.
70
+
71
+ If you disagree with a verdict, say so as a disagreement and leave the verdict
72
+ standing. "The judge flagged X, I think it is wrong because Y" is useful. Not
73
+ mentioning X is not.
74
+
75
+ Fix what you can, then run the whole thing again from step 1. A verdict on the
76
+ old screenshots is not a verdict on the new ones.
77
+
78
+ ## What this skill does not contain
79
+
80
+ Any design rules. The checklist is the user's file, named in their config, and
81
+ if it is empty then this reports nothing and that is correct. A checklist that
82
+ arrived with a tool is somebody else's taste wearing the tool's authority.
package/src/color.mjs CHANGED
@@ -25,7 +25,7 @@ const NAMED = {
25
25
  };
26
26
 
27
27
  /** Formats deliberately not supported in v1, named so the error is useful. */
28
- const KNOWN_UNSUPPORTED = ['lab', 'lch', 'color-mix', 'color'];
28
+ const KNOWN_UNSUPPORTED = ['color-mix', 'color'];
29
29
 
30
30
  const NUMBER = /^[+-]?(?:\d+\.?\d*|\.\d+)%?$/;
31
31
 
@@ -94,6 +94,45 @@ function oklabToRgb(L, a, b) {
94
94
  });
95
95
  }
96
96
 
97
+ /**
98
+ * CIELAB to sRGB.
99
+ *
100
+ * Longer than the OKLab path because CSS specifies lab() and lch() against the
101
+ * D50 white point while sRGB is D65, so a chromatic adaptation sits in the
102
+ * middle. The matrix below folds the adaptation and the XYZ-to-sRGB step
103
+ * together, which is how CSS Color 4 publishes it.
104
+ *
105
+ * Every constant here is checked against a browser rather than trusted. See
106
+ * the lab corpus in the tests: recalling a colour matrix correctly and
107
+ * recalling it confidently feel identical from the inside.
108
+ */
109
+ const D50 = [0.3457 / 0.3585, 1, (1 - 0.3457 - 0.3585) / 0.3585];
110
+ const XYZ_D50_TO_LINEAR_SRGB = [
111
+ [3.1341359569958707, -1.6173863321612538, -0.4906619460083532],
112
+ [-0.9787684456108496, 1.9161415707653082, 0.03344273116131949],
113
+ [0.07203000098937905, -0.22898208700867398, 1.4053851622747988],
114
+ ];
115
+
116
+ function labToRgb(L, a, b) {
117
+ const e = 216 / 24389;
118
+ const k = 24389 / 27;
119
+ const fy = (L + 16) / 116;
120
+ const fx = a / 500 + fy;
121
+ const fz = fy - b / 200;
122
+ const xr = fx ** 3 > e ? fx ** 3 : (116 * fx - 16) / k;
123
+ const yr = L > k * e ? fy ** 3 : L / k;
124
+ const zr = fz ** 3 > e ? fz ** 3 : (116 * fz - 16) / k;
125
+ const xyz = [xr * D50[0], yr * D50[1], zr * D50[2]];
126
+ const linear = XYZ_D50_TO_LINEAR_SRGB.map((row) => row.reduce((sum, m, i) => sum + m * xyz[i], 0));
127
+ return linear.map((c) => {
128
+ const v =
129
+ c <= 0.0031308
130
+ ? 12.92 * c
131
+ : 1.055 * Math.abs(c) ** (1 / 2.4) * Math.sign(c) - 0.055 * Math.sign(c);
132
+ return clamp(v, 0, 1) * 255;
133
+ });
134
+ }
135
+
97
136
  /** HSL to sRGB. h in turns, s and l in 0-1. */
98
137
  function hslToRgb(h, s, l) {
99
138
  const f = (n) => {
@@ -153,29 +192,39 @@ export function parseColor(input) {
153
192
  const fn = text.match(/^([a-zA-Z-]+)\s*\(([\s\S]*)\)$/);
154
193
  if (fn) {
155
194
  const name = fn[1].toLowerCase();
156
- if (name === 'oklch' || name === 'oklab') {
195
+ if (name === 'oklch' || name === 'oklab' || name === 'lab' || name === 'lch') {
196
+ const cie = name === 'lab' || name === 'lch';
197
+ const polarSpace = name === 'oklch' || name === 'lch';
157
198
  const [head, ...tail] = fn[2].split('/');
158
199
  if (tail.length > 1) return err(`"${text}" has more than one slash`);
159
200
  const parts = head.trim().split(/[,\s]+/).filter(Boolean);
160
201
  const alphaToken = tail.length ? tail[0].trim() : undefined;
161
202
  if (parts.length !== 3) return err(`"${text}" needs three components`);
162
203
 
163
- // Lightness is 0-1, or a percentage of that.
164
- const L = parts[0].endsWith('%') ? percent(parts[0]) : NUMBER.test(parts[0]) ? parseFloat(parts[0]) : null;
204
+ // Lightness runs 0-1 in the OK spaces and 0-100 in the CIE ones. A
205
+ // percentage means the same thing in both, scaled to whichever.
206
+ const top = cie ? 100 : 1;
207
+ const L = parts[0].endsWith('%')
208
+ ? percent(parts[0]) * top
209
+ : NUMBER.test(parts[0])
210
+ ? parseFloat(parts[0])
211
+ : null;
165
212
  if (L === null) return err(`"${text}" has a lightness that is not a number`);
213
+ // What 100% means for the other two components, per CSS Color 4.
214
+ const abFull = cie ? 125 : 0.4;
215
+ const chromaFull = cie ? 150 : 0.4;
166
216
 
167
217
  let a;
168
218
  let b;
169
- if (name === 'oklab') {
170
- // a and b are roughly -0.4 to 0.4; a percentage is relative to 0.4.
219
+ if (!polarSpace) {
171
220
  const ab = [parts[1], parts[2]].map((t) =>
172
- t.endsWith('%') ? percent(t) * 0.4 : NUMBER.test(t) ? parseFloat(t) : null,
221
+ t.endsWith('%') ? percent(t) * abFull : NUMBER.test(t) ? parseFloat(t) : null,
173
222
  );
174
223
  if (ab.some((v) => v === null)) return err(`"${text}" has a component that is not a number`);
175
224
  [a, b] = ab;
176
225
  } else {
177
226
  const C = parts[1].endsWith('%')
178
- ? percent(parts[1]) * 0.4
227
+ ? percent(parts[1]) * chromaFull
179
228
  : NUMBER.test(parts[1])
180
229
  ? parseFloat(parts[1])
181
230
  : null;
@@ -193,7 +242,8 @@ export function parseColor(input) {
193
242
  if (parsed === null) return err(`"${text}" has an alpha that is not a number`);
194
243
  alphaValue = parsed;
195
244
  }
196
- return ok([...oklabToRgb(clamp(L, 0, 1), a, b), clamp(alphaValue, 0, 1)]);
245
+ const rgb = cie ? labToRgb(clamp(L, 0, 100), a, b) : oklabToRgb(clamp(L, 0, 1), a, b);
246
+ return ok([...rgb, clamp(alphaValue, 0, 1)]);
197
247
  }
198
248
 
199
249
  const polar = name === 'hsl' || name === 'hsla' || name === 'hwb';
package/src/config.mjs CHANGED
@@ -133,14 +133,16 @@ function validateTreatments(treatments, errors) {
133
133
 
134
134
  function validateJudge(judge, errors) {
135
135
  rejectUnknown(judge, ['checklist', 'shots', 'shotCommand', 'command', 'failOn'], 'judge', errors);
136
- for (const key of ['checklist', 'command']) {
137
- if (typeof judge[key] !== 'string' || !judge[key]) {
136
+ if (typeof judge.checklist !== 'string' || !judge.checklist) {
137
+ errors.push('judge.checklist must be a non-empty string');
138
+ }
139
+ // command is optional: an agent carrying the call with --emit and --verdict
140
+ // never needs one, and demanding a placeholder would be theatre.
141
+ for (const key of ['command', 'shotCommand']) {
142
+ if (judge[key] !== undefined && (typeof judge[key] !== 'string' || !judge[key])) {
138
143
  errors.push(`judge.${key} must be a non-empty string`);
139
144
  }
140
145
  }
141
- if (judge.shotCommand !== undefined && (typeof judge.shotCommand !== 'string' || !judge.shotCommand)) {
142
- errors.push('judge.shotCommand must be a non-empty string');
143
- }
144
146
  stringArray(judge.shots, 'judge.shots', errors);
145
147
  if (judge.failOn !== undefined && judge.failOn !== 'never' && judge.failOn !== 'fail') {
146
148
  errors.push(
package/src/index.mjs CHANGED
@@ -4,7 +4,7 @@
4
4
  */
5
5
  export { runContrast } from './contrast.mjs';
6
6
  export { runTreatments } from './treatments.mjs';
7
- export { runJudge, buildPrompt, checklistLines, extractJson } from './judge.mjs';
7
+ export { runJudge, prepareJudge, gradeVerdict, buildPrompt, checklistLines, checklistEntries, extractJson } from './judge.mjs';
8
8
  export { runRuntime } from './runtime.mjs';
9
9
  export { connect, findBrowser } from './cdp.mjs';
10
10
  export { load, validate } from './config.mjs';
package/src/judge.mjs CHANGED
@@ -154,64 +154,98 @@ export function extractJson(stdout) {
154
154
 
155
155
  const VERDICTS = new Set(['pass', 'fail', 'unsure']);
156
156
 
157
- export function runJudge(config, cwd) {
158
- const findings = [];
159
- const problems = [];
160
- const { checklist: checklistPath, shots = [], shotCommand, command, failOn = 'never' } = config;
157
+ const empty = (problems, failOn) => ({ name: 'judge', findings: [], problems, failOn, summary: '' });
158
+
159
+ /**
160
+ * Everything the judge needs before a model is involved: the screenshots, the
161
+ * checklist, and the prompt built from them.
162
+ *
163
+ * Split out so the model call does not have to be a subprocess. A shell
164
+ * command is one way to reach a model and a poor one for an agent, which is
165
+ * already a model and can spawn a genuinely fresh context of its own rather
166
+ * than shelling out to a second copy of itself. This half prepares the call,
167
+ * `gradeVerdict` below checks the answer, and in between the transport is
168
+ * somebody else's problem.
169
+ *
170
+ * The preconditions are enforced here rather than at grading time. Handing
171
+ * back a prompt for zero screenshots would produce a confident verdict about
172
+ * nothing.
173
+ */
174
+ export function prepareJudge(config, cwd) {
175
+ const { checklist: checklistPath, shots = [], shotCommand, failOn = 'never' } = config;
161
176
 
162
177
  if (shotCommand) {
163
178
  const made = runCommand(shotCommand, [], '', cwd);
164
- if (!made.ok) {
165
- problems.push(made.reason);
166
- return { name: 'judge', findings, problems, failOn, summary: '' };
167
- }
179
+ if (!made.ok) return { ok: false, result: empty([made.reason], failOn) };
168
180
  }
169
181
 
170
182
  const images = expand(shots, cwd);
171
183
  if (!images.length) {
172
- problems.push(
173
- `no screenshots matched ${shots.map((s) => `"${s}"`).join(', ')}. ` +
174
- `A judge with nothing to look at cannot fail, so it does not get to pass either.`,
175
- );
176
- return { name: 'judge', findings, problems, failOn, summary: '' };
184
+ return {
185
+ ok: false,
186
+ result: empty(
187
+ [
188
+ `no screenshots matched ${shots.map((s) => `"${s}"`).join(', ')}. ` +
189
+ `A judge with nothing to look at cannot fail, so it does not get to pass either.`,
190
+ ],
191
+ failOn,
192
+ ),
193
+ };
177
194
  }
178
195
 
179
196
  let entries;
180
197
  try {
181
198
  entries = checklistEntries(readFileSync(resolve(cwd, checklistPath), 'utf8'));
182
199
  } catch {
183
- problems.push(`cannot read the checklist at ${checklistPath}`);
184
- return { name: 'judge', findings, problems, failOn, summary: '' };
200
+ return { ok: false, result: empty([`cannot read the checklist at ${checklistPath}`], failOn) };
185
201
  }
186
- const lines = entries.map((e) => e.text);
187
- const lineNumbers = new Map(entries.map((e) => [e.text, e.line]));
188
- if (!lines.length) {
189
- problems.push(
190
- `${checklistPath} has no checklist lines in it. Lines to judge are list ` +
191
- `items ("- ..." or "1. ..."); everything else is treated as prose.`,
192
- );
193
- return { name: 'judge', findings, problems, failOn, summary: '' };
202
+ if (!entries.length) {
203
+ return {
204
+ ok: false,
205
+ result: empty(
206
+ [
207
+ `${checklistPath} has no checklist lines in it. Lines to judge are list ` +
208
+ `items ("- ..." or "1. ..."); everything else is treated as prose.`,
209
+ ],
210
+ failOn,
211
+ ),
212
+ };
194
213
  }
195
214
 
196
- const reply = runCommand(command, images, buildPrompt(lines, images.map((i) => label(i, cwd))), cwd);
197
- if (!reply.ok) {
198
- problems.push(reply.reason);
199
- return { name: 'judge', findings, problems, failOn, summary: '' };
200
- }
215
+ const relative = images.map((i) => label(i, cwd));
216
+ return {
217
+ ok: true,
218
+ entries,
219
+ images,
220
+ relativeImages: relative,
221
+ prompt: buildPrompt(entries.map((e) => e.text), relative),
222
+ };
223
+ }
224
+
225
+ /**
226
+ * Check a reply against the checklist it was supposed to answer.
227
+ *
228
+ * This is the half that makes the whole thing trustworthy, and it does not
229
+ * care where the reply came from. A judge that quietly drops the hardest line
230
+ * is the failure mode to guard: every remaining verdict says pass, and the one
231
+ * nobody answered is the one that mattered.
232
+ */
233
+ export function gradeVerdict(reply, prepared, config) {
234
+ const { checklist: checklistPath, failOn = 'never' } = config;
235
+ const findings = [];
236
+ const problems = [];
237
+ const { entries, images } = prepared;
238
+ const lines = entries.map((e) => e.text);
239
+ const lineNumbers = new Map(entries.map((e) => [e.text, e.line]));
201
240
 
202
- const parsed = extractJson(reply.stdout);
241
+ const parsed = extractJson(reply);
203
242
  if (!parsed.ok) {
204
- problems.push(`${parsed.reason}. The judge must reply with the documented JSON shape.`);
205
- return { name: 'judge', findings, problems, failOn, summary: '' };
243
+ return empty([`${parsed.reason}. The judge must reply with the documented JSON shape.`], failOn);
206
244
  }
207
245
  if (!Array.isArray(parsed.value.findings)) {
208
- problems.push('the reply has no "findings" array');
209
- return { name: 'judge', findings, problems, failOn, summary: '' };
246
+ return empty(['the reply has no "findings" array'], failOn);
210
247
  }
211
248
 
212
- // Cross-check both directions. A judge that quietly drops the hardest line
213
- // is the failure mode to guard: the remaining verdicts all say pass, and
214
- // the line nobody answered is the one that mattered.
215
249
  const wanted = new Set(lines);
216
250
  const answered = new Set();
217
251
  for (const f of parsed.value.findings) {
@@ -246,6 +280,27 @@ export function runJudge(config, cwd) {
246
280
  failOn,
247
281
  summary: `${lines.length} ${lines.length === 1 ? 'line' : 'lines'} against ${images.length} ${
248
282
  images.length === 1 ? 'screenshot' : 'screenshots'
249
- } (${images.map((i) => label(i, cwd)).join(', ')})`,
283
+ } (${prepared.relativeImages.join(', ')})`,
250
284
  };
251
285
  }
286
+
287
+ /** The whole thing, with a configured command as the transport. */
288
+ export function runJudge(config, cwd) {
289
+ const { command, failOn = 'never' } = config;
290
+ const prepared = prepareJudge(config, cwd);
291
+ if (!prepared.ok) return prepared.result;
292
+
293
+ if (!command) {
294
+ return empty(
295
+ [
296
+ 'judge.command is not set, so there is nothing to ask. Set it, or use ' +
297
+ '`taste-check judge --emit` and `--verdict` to let an agent carry the call.',
298
+ ],
299
+ failOn,
300
+ );
301
+ }
302
+
303
+ const reply = runCommand(command, prepared.images, prepared.prompt, cwd);
304
+ if (!reply.ok) return empty([reply.reason], failOn);
305
+ return gradeVerdict(reply.stdout, prepared, config);
306
+ }