@theaiteam/promptdiff 1.0.0-rc.1

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 ADDED
@@ -0,0 +1,697 @@
1
+ # promptdiff
2
+
3
+ `promptdiff` is a small Bun CLI for testing whether an LLM prompt or skill
4
+ change actually changes behavior — with any model, not just Claude.
5
+
6
+ It has four commands:
7
+
8
+ - `run`: one bounded model invocation with an agent file and optional inlined
9
+ skill files.
10
+ - `compare`: N-run baseline-vs-proposed comparison from a JSON scenario file,
11
+ with deterministic text or command graders (plus calibrated LLM judges).
12
+ - `measure`: N-run single-arm characterization — pass rates, no assertions.
13
+ - `calibrate`: prove a judge grader against labeled rubric fixtures before
14
+ compare/measure will let it grade anything.
15
+
16
+ Model access goes through pluggable runners. Two ship today:
17
+
18
+ - `claude-p` (default): headless Claude Code via `claude -p`. Supports tools,
19
+ sandboxed artifact runs, and skill-registry install testing.
20
+ - `openai`: a single chat completion against any OpenAI-compatible endpoint
21
+ (OpenAI, ollama, vLLM, llama.cpp, OpenRouter, ...). Text-graded evals only.
22
+
23
+ The project is still early, but the CLI performs real repeated comparisons;
24
+ provider-specific code is isolated in `src/runner/`.
25
+
26
+ ## Why it exists
27
+
28
+ Prompt and skill edits are easy to propose and hard to trust. For a recurring
29
+ defect, a useful eval should show two things:
30
+
31
+ 1. the baseline instruction set still reproduces the failure, and
32
+ 2. the proposed instruction set improves the target case without regressing
33
+ existing scenarios.
34
+
35
+ `promptdiff compare` encodes that loop. It runs each arm several times, grades
36
+ each output deterministically, and reports pass rates and cost.
37
+
38
+ ## Install
39
+
40
+ ```bash
41
+ bun add -g @theaiteam/promptdiff
42
+ # or
43
+ npm install -g @theaiteam/promptdiff
44
+ ```
45
+
46
+ Or from a checkout:
47
+
48
+ ```bash
49
+ git clone https://github.com/queso/promptdiff
50
+ cd promptdiff
51
+ bun install
52
+ ```
53
+
54
+ Runtime requirements:
55
+
56
+ ```bash
57
+ bun --version
58
+ claude --version # only for the default claude-p runner
59
+ ```
60
+
61
+ Run from the repo:
62
+
63
+ ```bash
64
+ ./promptdiff --help
65
+ ```
66
+
67
+ ## Safe Defaults
68
+
69
+ Paid model calls are bounded by default:
70
+
71
+ - `--max-budget-usd 1` per invocation — enforced by claude-p natively, and by
72
+ the openai runner whenever `pricing` is set (unpriced openai runs report $0
73
+ and are bounded only by being single completions)
74
+ - `--timeout-ms 600000` per invocation
75
+ - fresh sandbox working directories under `.promptdiff/`
76
+ - `run --mode text` disables tools with `--tools ""`
77
+ - artifact mode uses the sandbox as the agent's actual `cwd`
78
+
79
+ For artifact-producing runs, use `--mode artifact`; by default that enables
80
+ the agent's default tools and keeps the sandbox for inspection. Pass
81
+ `--clean-sandbox` to delete it after a single run.
82
+
83
+ **Budget sizing:** the default `$1` cap is tuned for text-mode runs. An
84
+ artifact-mode run with default tools on even a small fixture can measure
85
+ `~$0.86` — right at the cap — and the cap is only checked between turns, so
86
+ runs need headroom. Set `maxBudgetUsd` to `3` or more for artifact scenarios.
87
+ A run that hits the cap fails with an explicit
88
+ `claude hit the $N max budget` error rather than a silent bad sample.
89
+
90
+ ## Runners
91
+
92
+ `--runner claude-p` (default) shells out to headless Claude Code and supports
93
+ everything: tools, artifact mode, command graders, and `--delivery install`.
94
+
95
+ `--runner openai` sends one chat completion to an OpenAI-compatible endpoint:
96
+
97
+ ```bash
98
+ OPENAI_API_KEY=sk-... ./promptdiff run \
99
+ --runner openai \
100
+ --model gpt-4o-mini \
101
+ --agent ./agents/ba.md \
102
+ --skill ./skills/defensive-coding/SKILL.md \
103
+ --prompt "Explain how you would validate POST /api/items."
104
+ ```
105
+
106
+ The endpoint defaults to `https://api.openai.com/v1` and can be changed with
107
+ `--base-url` or `$OPENAI_BASE_URL` — point it at ollama, vLLM, llama.cpp,
108
+ OpenRouter, or anything else that speaks `/chat/completions`. `$OPENAI_API_KEY`
109
+ is sent as a bearer token when set; local servers work without one.
110
+
111
+ ```bash
112
+ ./promptdiff run \
113
+ --runner openai \
114
+ --base-url http://localhost:11434/v1 \
115
+ --model llama3.1 \
116
+ --agent ./agents/ba.md \
117
+ --prompt "Explain how you would validate POST /api/items."
118
+ ```
119
+
120
+ The openai runner is text-only: no tools, no sandbox execution, no skill
121
+ registry. Scenarios that need artifact mode, command graders, or install
122
+ delivery are rejected up front — before any paid run — with an error telling
123
+ you to use claude-p. In a scenario file, select it with top-level
124
+ `"runner": "openai"` and optionally `"baseUrl": "..."`.
125
+
126
+ ### Vision evals (VLMs)
127
+
128
+ The openai runner can attach images to the user message, so prompt A/B tests
129
+ work against vision-language models too. On `run`, pass `--image`
130
+ (repeatable):
131
+
132
+ ```bash
133
+ ./promptdiff run \
134
+ --runner openai \
135
+ --base-url http://localhost:11434/v1 \
136
+ --model qwen2.5vl \
137
+ --agent ./agents/alt-text.md \
138
+ --image ./fixtures/screenshot.png \
139
+ --prompt "Write alt text for this screenshot."
140
+ ```
141
+
142
+ In a scenario file, any scenario may list `"images"`:
143
+
144
+ ```json
145
+ {
146
+ "name": "alt-text-quality",
147
+ "kind": "target",
148
+ "prompt": "Write alt text for this screenshot.",
149
+ "images": ["./fixtures/screenshot.png"],
150
+ "grader": { "type": "text", "notContains": ["image of"] }
151
+ }
152
+ ```
153
+
154
+ Image paths resolve relative to the scenario file and are checked at load
155
+ time, so a missing file fails before any paid run. Supported formats: jpg,
156
+ jpeg, png, webp, gif. Files are embedded as base64 data URIs — no upload
157
+ endpoint needed, so local servers (ollama, vLLM) work as-is. The claude-p
158
+ runner does not support image attachment; a scenario with `images` on a
159
+ claude-p arm is rejected up front.
160
+
161
+ ### Endpoint options (scenario file)
162
+
163
+ Two more top-level scenario fields tune the openai runner in `compare`:
164
+
165
+ - `"requestParams": { "max_tokens": 512, "temperature": 0 }` — extra fields
166
+ merged into the chat-completions request body (they can never clobber
167
+ `model` or `messages`). Useful for pinning temperature so pass-rate deltas
168
+ reflect the prompt, not sampling noise.
169
+ - `"pricing": { "gpt-4o-mini": { "input": 0.15, "output": 0.60 } }` — USD per
170
+ million input/output tokens, keyed by model (so mixed-model compares price
171
+ each arm correctly). With pricing set, cost columns are computed from each
172
+ response's `usage` and `maxBudgetUsd` actually enforces; a priced endpoint
173
+ that returns no usage fails loudly instead of reporting $0. Unpriced openai
174
+ arms keep reporting $0, which is the truth for local servers. On `run`, the
175
+ equivalent is `--price 0.15,0.60`.
176
+ - `"retries": 2` (the default) — extra attempts after a *transient* failure:
177
+ timeout, connection error, HTTP 429 or 5xx, with exponential backoff and a
178
+ per-attempt timeout. One `503 service overloaded` no longer throws away a
179
+ whole compare. Deterministic failures (other 4xx, malformed responses)
180
+ still fail immediately without burning retries. Set `"retries": 0` to
181
+ disable.
182
+
183
+ ## Single Run
184
+
185
+ Text-only eval:
186
+
187
+ ```bash
188
+ ./promptdiff run \
189
+ --agent ./agents/ba.md \
190
+ --skill ./skills/defensive-coding/SKILL.md \
191
+ --model sonnet \
192
+ --prompt "Explain how you would validate POST /api/items."
193
+ ```
194
+
195
+ Artifact-producing eval:
196
+
197
+ ```bash
198
+ ./promptdiff run \
199
+ --agent ./agents/ba.md \
200
+ --skill ./skills/defensive-coding/SKILL.md \
201
+ --model sonnet \
202
+ --mode artifact \
203
+ --seed ./fixtures/wi-203 \
204
+ --sandbox .promptdiff/manual \
205
+ --prompt "FIXTURE WI-203: Add GET /api/health returning {\"status\":\"ok\"}. Tests exist."
206
+ ```
207
+
208
+ ## Compare
209
+
210
+ Create a scenario file:
211
+
212
+ ```json
213
+ {
214
+ "name": "wi-203 health route",
215
+ "agent": "./agents/ba.md",
216
+ "baselineSkills": ["./skills/defensive-coding.baseline.md"],
217
+ "proposedSkills": ["./skills/defensive-coding.proposed.md"],
218
+ "model": "sonnet",
219
+ "runs": 5,
220
+ "maxBudgetUsd": 1,
221
+ "timeoutMs": 600000,
222
+ "sandbox": {
223
+ "root": ".promptdiff/runs",
224
+ "seed": "./fixtures/wi-203"
225
+ },
226
+ "scenarios": [
227
+ {
228
+ "name": "target-health-route",
229
+ "kind": "target",
230
+ "prompt": "Add GET /api/health returning {\"status\":\"ok\"}. Existing tests define the desired behavior.",
231
+ "grader": {
232
+ "type": "command",
233
+ "command": "bun test",
234
+ "timeoutMs": 120000
235
+ }
236
+ },
237
+ {
238
+ "name": "regression-existing-tests",
239
+ "kind": "regression",
240
+ "prompt": "Make no functional changes. Ensure the existing app still passes tests.",
241
+ "grader": {
242
+ "type": "command",
243
+ "command": "bun test",
244
+ "timeoutMs": 120000
245
+ }
246
+ }
247
+ ]
248
+ }
249
+ ```
250
+
251
+ Paths inside a scenario file are resolved relative to that scenario file.
252
+
253
+ Run it:
254
+
255
+ ```bash
256
+ ./promptdiff compare --scenario ./scenarios/wi-203.json
257
+ ```
258
+
259
+ Useful overrides:
260
+
261
+ ```bash
262
+ ./promptdiff compare \
263
+ --scenario ./scenarios/wi-203.json \
264
+ --baseline ./skills/current/SKILL.md \
265
+ --proposed ./skills/proposed/SKILL.md \
266
+ --runs 8 \
267
+ --keep-sandbox
268
+ ```
269
+
270
+ `compare` exits non-zero when assertions fail. For target scenarios, baseline
271
+ must not fully pass and proposed must improve the pass rate. For regression
272
+ scenarios, proposed must not fall below baseline.
273
+
274
+ Reading results:
275
+
276
+ - Failing runs print their grader message plus the tail of grader
277
+ stdout/stderr directly in the summary, so one compare run tells you *which*
278
+ check missed without a `--keep-sandbox` re-run.
279
+ - Deltas that sampling noise could explain are labelled
280
+ (`NOTE: delta could be sampling noise (Fisher exact p=0.47)`). Assertions
281
+ and exit codes are unchanged — the label keeps a 1/3 → 2/3 "win" from
282
+ reading like a receipt. At n=3 per arm, even 0/3 → 3/3 only reaches p=0.10;
283
+ use more runs when the claim matters.
284
+ - Declare `"productionModel": "gpt-5.5"` and any arm testing a different
285
+ model gets a warning on the summary — a pass on the wrong model validates
286
+ prompt logic, not production behavior.
287
+
288
+ ### Run history
289
+
290
+ `--report ndjson --report-out ./runs.ndjson` appends one record per scenario
291
+ per invocation: timestamp, arms (model, runner, passes, cost), delta,
292
+ sampling p, failed assertions, sha256 hashes of each arm's rendered system
293
+ prompt, and `productionModel`. Append-only NDJSON — diffable, greppable, and
294
+ queryable months later ("has the catch rate drifted since July?") without
295
+ hand-transcribing summaries. Failed comparisons are recorded too.
296
+
297
+ ### Receipts
298
+
299
+ `--receipts <dir>` (on `compare` and `measure`) writes one
300
+ `<scenario>.receipt.json` per scenario, overwritten each run — a receipt is
301
+ *current state*; the ndjson report is the history. Each receipt records the
302
+ repo-relative path and content sha256 of the agent and every skill file
303
+ (install-delivery skill directories get a deterministic tree hash covering
304
+ supporting files), the arm results, sampling p, and a verdict: `pass`/`fail`
305
+ for asserted scenarios, `none` for `"kind": "compare"`, `measured` for
306
+ measure runs.
307
+
308
+ This replaces hand-maintained `prompt_version` strings with content
309
+ addressing: a consuming repo's CI can assert that every prompt it ships has
310
+ a passing receipt for its **current** hash —
311
+
312
+ ```bash
313
+ current=$(sha256sum flows/post/prompts/editorial.md | cut -d' ' -f1)
314
+ jq -e --arg h "$current" \
315
+ '.verdict == "pass" and ([.prompts.proposedSkills[].sha256] | index($h))' \
316
+ receipts/editorial-gate.receipt.json
317
+ ```
318
+
319
+ Edit the prompt and the hash changes, the receipt goes stale, and the check
320
+ names exactly which scenario to re-run. No version bump to remember, no way
321
+ to ship a prompt whose eval never ran.
322
+
323
+ ### Caching the baseline arm
324
+
325
+ A baseline arm is usually frozen by construction — it reproduces a known
326
+ failure and never changes — yet every compare re-runs it, so about half of
327
+ each iteration's cost re-proves something already recorded. `--cache` reuses
328
+ recorded baseline results instead:
329
+
330
+ ```bash
331
+ ./promptdiff compare --scenario ./scenarios/wi-203.json --cache
332
+ ```
333
+
334
+ Results land in `.promptdiff/cache` (override with `--cache-dir <dir>`; it
335
+ requires `--cache`). Caching is opt-in because a cache that is silently on is
336
+ a cache that is silently stale. The key hashes content, not paths, so a hit
337
+ means nothing outcome-relevant changed: the rendered baseline system prompt
338
+ (agent + skill text + render vars), the scenario prompt, the baseline
339
+ model/runner, the run count, tools/mode/delivery, the grader spec, image file
340
+ contents, and the sandbox seed tree. Change any of those and the next compare
341
+ runs the baseline fresh. The proposed arm — the thing being iterated — always
342
+ runs fresh, and cached baselines are real graded results, so assertions,
343
+ sampling-p notes, and reports work unchanged; the summary marks the baseline
344
+ line with `(cached)`. Delete the cache dir to bust it.
345
+
346
+ ## Measure: characterize before you change
347
+
348
+ `compare` answers "did the change help?" — `measure` answers the question
349
+ that comes first: *what does the current prompt actually do?* One arm,
350
+ per-case pass rates, no delta and no assertions:
351
+
352
+ ```bash
353
+ ./promptdiff measure --scenario ./scenarios/survival.json
354
+ ```
355
+
356
+ ```json
357
+ {
358
+ "name": "finding survival",
359
+ "agent": "./agents/reviewer.md",
360
+ "skills": ["../flows/review/prompts/coordinate.md"],
361
+ "model": "sonnet",
362
+ "runs": 16,
363
+ "scenarios": [
364
+ {
365
+ "name": "true-finding-survives",
366
+ "prompt": "Review this diff.",
367
+ "grader": { "type": "text", "contains": ["unbounded-network-call"] }
368
+ }
369
+ ]
370
+ }
371
+ ```
372
+
373
+ The scenario file is the compare format — render vars, images, pricing,
374
+ `productionModel`, and both grader types all apply — but only `skills` (or
375
+ `baselineSkills`) is required; no proposed arm. Don't fake this with
376
+ identical compare arms: two identical arms at small n routinely produce
377
+ verdicts like `FAIL: proposed regressed below baseline` out of pure sampling
378
+ noise. `measure` exits 0 whenever the runs complete — a measurement has no
379
+ pass/fail.
380
+
381
+ ## Comparing models
382
+
383
+ The A/B variable does not have to be the skill text. Hold the prompt and
384
+ skills constant and vary the model — or the runner and endpoint — per arm:
385
+
386
+ ```json
387
+ {
388
+ "name": "sonnet vs local llama3.1",
389
+ "agent": "./agents/ba.md",
390
+ "skills": ["./skills/defensive-coding/SKILL.md"],
391
+ "baseline": { "model": "sonnet", "runner": "claude-p" },
392
+ "proposed": {
393
+ "model": "llama3.1",
394
+ "runner": "openai",
395
+ "baseUrl": "http://localhost:11434/v1"
396
+ },
397
+ "runs": 5,
398
+ "scenarios": [
399
+ {
400
+ "name": "validation-explanation",
401
+ "kind": "compare",
402
+ "prompt": "Explain how you would validate POST /api/items.",
403
+ "grader": { "type": "text", "contains": ["status"], "notContains": ["TODO"] }
404
+ }
405
+ ]
406
+ }
407
+ ```
408
+
409
+ - A top-level `"skills"` array is inherited by both arms when an arm defines
410
+ none of its own; each arm may still bring its own `"skills"`.
411
+ - The record form of `"baseline"`/`"proposed"` accepts `"model"`, `"runner"`,
412
+ and `"baseUrl"`, each falling back to the shared top-level value.
413
+ - `"kind": "compare"` makes no directional claim: it never fails the run, it
414
+ just reports both arms' pass rates and the delta. Target and regression
415
+ scenarios still work in model comparisons if you do want an assertion.
416
+ - `--baseline-model`/`--proposed-model` and
417
+ `--baseline-runner`/`--proposed-runner` override per arm from the CLI.
418
+
419
+ Each arm is validated against its own runner's capabilities before any paid
420
+ run, so an openai arm still rejects command graders, tools, artifact mode,
421
+ and install delivery up front.
422
+
423
+ ## Testing templated prompts
424
+
425
+ Production prompts often contain template placeholders (`{{draft}}`,
426
+ `{{voice}}`) that a pipeline fills in at runtime. `render` lets a scenario
427
+ point at the real prompt file and supply the bindings, so the eval tests what
428
+ ships — not a hand-copied "rendered variant" that drifts:
429
+
430
+ ```json
431
+ {
432
+ "agent": "./agents/editor.md",
433
+ "baselineSkills": ["../flows/post/prompts/editorial.md"],
434
+ "proposedSkills": ["./editorial.tightened.md"],
435
+ "model": "sonnet",
436
+ "render": { "vars": { "voice": "../voice/personal.md" } },
437
+ "scenarios": [
438
+ {
439
+ "name": "catches-scope-overstatement",
440
+ "kind": "target",
441
+ "prompt": "Apply the editorial gate.",
442
+ "render": { "vars": { "draft": "./fixtures/draft-scope-overstatement.md" } },
443
+ "grader": { "type": "text", "regex": ["REJECT"] }
444
+ }
445
+ ]
446
+ }
447
+ ```
448
+
449
+ - Var values resolve as paths relative to the scenario file: a value naming
450
+ an existing file is read as its contents; anything else is used literally.
451
+ Files are read at load time, so a missing fixture fails before any paid run.
452
+ - Bindings apply to the agent body, inlined skill text, and scenario prompts.
453
+ A scenario's `render` merges over the top-level one (scenario wins per var)
454
+ — bind shared vars once, vary the fixture per scenario.
455
+ - Rendering is strict: when any `render` block is present, unbound
456
+ `{{placeholders}}` abort before any paid run, so `{{draft}}` never silently
457
+ reaches a model. Without a `render` block, braces pass through untouched,
458
+ as before.
459
+ - Inline delivery only — install delivery copies skill files verbatim, so
460
+ placeholders can't be bound there.
461
+ - `run` gets the same behavior via `--var name=value` (repeatable), handy
462
+ while developing fixtures.
463
+
464
+ Placeholder syntax is `{{name}}`, with inner whitespace tolerated
465
+ (`{{ draft }}`).
466
+
467
+ Two caveats when the arms use different runners: claude-p arms are agentic
468
+ (tools, multiple turns) while openai arms are single completions, so pass
469
+ rates compare but the mechanics differ; and the openai runner reports $0 cost
470
+ (tokens only), so the summary flags the cost columns as not comparable.
471
+
472
+ ## Delivery: inline vs install
473
+
474
+ Two ways to put the skill under test in front of the agent, for two different
475
+ questions:
476
+
477
+ - `inline` (default): skill bodies are inlined into a replaced system prompt,
478
+ frontmatter stripped. Tests **compliance** — given the skill text is in
479
+ context, does behavior follow it? Fully controlled, works with `--tools ""`.
480
+ - `install`: skill directories are copied to `<sandbox>/.claude/skills/<name>`
481
+ with frontmatter intact, and the agent text is appended to the **default**
482
+ system prompt (`--append-system-prompt`) so the harness's skill registry
483
+ stays active. Tests **invocation** — does the frontmatter description get
484
+ the skill triggered at all? Requires tools (the Skill tool does the
485
+ triggering); implies `--mode artifact`. Claude Code only (`--runner claude-p`):
486
+ it exercises that harness's skill registry, which plain completion endpoints
487
+ do not have.
488
+
489
+ ```bash
490
+ ./promptdiff run \
491
+ --agent ./agents/probe.md \
492
+ --skill ./skills/deploy-checklist \
493
+ --delivery install \
494
+ --model sonnet \
495
+ --prompt "Where should this app be deployed?"
496
+ ```
497
+
498
+ In a scenario file, set top-level `"delivery": "install"` and point
499
+ `baselineSkills`/`proposedSkills` at skill *directories* (or their SKILL.md;
500
+ the parent directory is copied either way). Each run gets a fresh install in
501
+ its own sandbox.
502
+
503
+ Caveat: a user-level skill with the same name (`~/.claude/skills/<name>` or
504
+ `$CLAUDE_CONFIG_DIR/skills/<name>`) loads in every run of both arms and
505
+ contaminates the comparison. promptdiff warns when it detects this; remove or
506
+ rename the user-level copy before comparing.
507
+
508
+ ## Graders
509
+
510
+ Text grader:
511
+
512
+ ```json
513
+ {
514
+ "type": "text",
515
+ "contains": ["status"],
516
+ "notContains": ["TODO"],
517
+ "regex": ["health"]
518
+ }
519
+ ```
520
+
521
+ JSON grader:
522
+
523
+ ```json
524
+ {
525
+ "type": "json",
526
+ "assert": [
527
+ "findings.items.length >= 1",
528
+ "findings.items[*].domain contains \"correctness\""
529
+ ]
530
+ }
531
+ ```
532
+
533
+ The json grader parses the run's output as JSON and checks each `assert`
534
+ entry; all must hold. Reasoning models wrap their answer in prose, so if the
535
+ whole output is not valid JSON, the grader takes the **last balanced JSON
536
+ value** (`{...}` or `[...]`) in the output — braces inside string literals
537
+ are handled correctly. No JSON value at all fails the grade with
538
+ `no JSON value found in output`.
539
+
540
+ Each assertion is `<path> <op> <literal>` (spaces around the operator):
541
+
542
+ - **path** — dot-separated keys with `[<index>]` and `[*]` steps:
543
+ `verdict`, `findings.items.length`, `findings.items[0].severity`,
544
+ `findings.items[*].domain`. A trailing `.length` on an array or string
545
+ reads its length.
546
+ - **op** — `==`, `!=`, `>`, `>=`, `<`, `<=`, `contains` (substring on a
547
+ string, membership on an array).
548
+ - **literal** — a JSON scalar: `"correctness"`, `0`, `true`, `null`.
549
+
550
+ `[*]` is existential: the assertion passes if **any** element satisfies it —
551
+ including `!=`, where `items[*].x != 1` means "some element differs". For
552
+ "no element equals", assert on a value that must not appear another way
553
+ (e.g. combine with a `length` bound or use a `[<index>]` path). Missing
554
+ paths and type mismatches fail the assertion (with a message naming the
555
+ problem), never the whole run. Bad assertion grammar fails at config load,
556
+ before any paid run. Like text graders, json graders inspect only the final
557
+ output, so they work with every runner, including openai.
558
+
559
+ Command grader:
560
+
561
+ ```json
562
+ {
563
+ "type": "command",
564
+ "command": "bun test",
565
+ "cwd": ".",
566
+ "timeoutMs": 120000,
567
+ "expectExitCode": 0
568
+ }
569
+ ```
570
+
571
+ Command graders run inside the per-run sandbox. Typically they grade files
572
+ the agent wrote there, which needs a tool-capable runner (claude-p); text
573
+ graders work with every runner. For semantic judgments neither can express,
574
+ see [Judge graders (calibrated)](#judge-graders-calibrated) below.
575
+
576
+ Every command grader also receives the run's final output, written into the
577
+ sandbox and exposed as `$PROMPTDIFF_OUTPUT_FILE`. That means completion-style
578
+ runs (openai runner) can be command-graded too — set `"mode": "text"` on the
579
+ scenario so no tools are demanded, and script against the file:
580
+
581
+ ```json
582
+ {
583
+ "name": "structured-answer",
584
+ "kind": "target",
585
+ "mode": "text",
586
+ "prompt": "Reply with JSON: {\"status\": ...}",
587
+ "grader": {
588
+ "type": "command",
589
+ "command": "jq -e '.status == \"ok\"' \"$PROMPTDIFF_OUTPUT_FILE\""
590
+ }
591
+ }
592
+ ```
593
+
594
+ ## Judge graders (calibrated)
595
+
596
+ Some judgments cannot be expressed as string checks — "is this the
597
+ negate-then-restate construction?" is a semantic call, and regex attempts
598
+ both under- and over-catch it. The `judge` grader has an LLM grade the run's
599
+ output against a markdown rubric:
600
+
601
+ ```json
602
+ {
603
+ "type": "judge",
604
+ "rubric": "./rubrics/negate-restate.md",
605
+ "model": "haiku",
606
+ "runner": "claude-p",
607
+ "minAccuracy": 0.9
608
+ }
609
+ ```
610
+
611
+ The rubric file becomes the judge's system prompt verbatim (plus a fixed
612
+ harness instruction demanding a single JSON verdict), and the graded output
613
+ becomes the user prompt. `model` is required and deliberately never defaults
614
+ to the arm's model — a model grading its own output is the bias judges exist
615
+ to avoid. `runner` defaults to `claude-p`; `baseUrl` targets the openai
616
+ runner at any OpenAI-compatible endpoint (judges there are pinned to
617
+ temperature 0).
618
+
619
+ **Calibration is mandatory.** An uncalibrated judge is worse than the regex
620
+ it replaces: same wrongness, more confidence, higher cost. So every rubric
621
+ ships with labeled fixtures in a sibling directory:
622
+
623
+ ```text
624
+ rubrics/negate-restate.md
625
+ rubrics/negate-restate.fixtures/
626
+ pass/ outputs the judge must call clean
627
+ fail/ outputs the judge must flag
628
+ ```
629
+
630
+ Run the calibration before trusting the judge:
631
+
632
+ ```bash
633
+ promptdiff calibrate --rubric rubrics/negate-restate.md --model haiku
634
+ ```
635
+
636
+ This judges every fixture, prints per-class accuracy plus each miss, and
637
+ writes `rubrics/negate-restate.md.calibration.json` next to the rubric —
638
+ commit it; it is the judge's proof of competence, keyed to the rubric's
639
+ content hash. Calibrate always exits 0: it measures, the gate enforces.
640
+
641
+ **The gate.** At compare/measure startup — before any paid run — every judge
642
+ grader must have a calibration record that exists, matches the current
643
+ rubric content (editing the rubric stales the record) and the spec's
644
+ model/runner, and clears `minAccuracy` (default 0.9) on **both** classes.
645
+ Per-class bars on purpose: a judge that passes everything scores 100% on the
646
+ pass class and 0% on the fail class — overall accuracy would hide it. Any
647
+ violation refuses the whole run and names the fix.
648
+
649
+ Judge verdicts are strict: the last balanced JSON object in the judge's
650
+ reply wins (reasoning models add prose), and a reply with no valid verdict
651
+ **fails** the graded run — a judge problem never silently counts as a pass.
652
+
653
+ Cost note: a judge grader adds one billed model call per graded run; that
654
+ cost is added into the run's cost so summary totals stay honest. v1 judges
655
+ are absolute (one output vs the rubric); pairwise comparison is deferred —
656
+ a rubric whose absolute judge cannot clear the calibration bar is the signal
657
+ to escalate.
658
+
659
+ ## Designing good fixtures
660
+
661
+ Lessons from production compare runs, for scenario authors:
662
+
663
+ - **Don't seed the answer key.** If the primary sources live inside the
664
+ sandbox, any thorough agent can "verify" claims by adjacency and both arms
665
+ pass for the wrong reason. Cite out-of-sandbox paths or URLs so
666
+ re-derivation is the discriminating behavior.
667
+ - **Planted defects must be unambiguous.** A one-word quote diff grades as
668
+ pedantry; a clear paraphrase grades cleanly. The answer key belongs in the
669
+ grader — never in the fixture itself.
670
+ - **Set the pass bar where the policy value is.** Mechanical holes (wrong
671
+ dates, dead links) fall to any tools-capable agent; the bar has to require
672
+ the defect classes only the prompt-under-test knows about, or the eval
673
+ can't tell your prompt from no prompt.
674
+ - **Prove the gap exists before trusting the fix.** That's what target
675
+ scenarios' "baseline must not fully pass" assertion is for — a fixture the
676
+ baseline aces can't certify an improvement.
677
+
678
+ ## Security note
679
+
680
+ Command graders execute arbitrary shell commands from scenario files (inside
681
+ the per-run sandbox, but with your local permissions). Only run scenario files
682
+ you trust.
683
+
684
+ ## Development
685
+
686
+ ```bash
687
+ bun test
688
+ bun run typecheck
689
+ bun run check
690
+ ```
691
+
692
+ See [SPEC.md](./SPEC.md) for design notes, limitations, and the reasoning behind
693
+ inlining skill variants into the system prompt.
694
+
695
+ ## License
696
+
697
+ [MIT](./LICENSE)