@josueavalosjim/taste-check 0.6.0 → 0.7.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. |
@@ -453,8 +482,6 @@ Not built. Written down so the shape is clear.
453
482
 
454
483
  **YAML configs**, once there is a reason to take on a parser.
455
484
 
456
- **A way to run the judge from an agent skill**, not only from a shell.
457
-
458
485
  **`lab()` and `lch()`**, which need the D50 white point and a chromatic
459
486
  adaptation step that `oklch()` does not. Completeness rather than reach, so
460
487
  it sits behind the others. Worth doing the same way when it happens: derive it,
@@ -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.7.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/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
+ }