@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/CHANGELOG.md +48 -0
- package/LICENSE +21 -0
- package/README.md +697 -0
- package/SPEC.md +314 -0
- package/package.json +54 -0
- package/promptdiff +4 -0
- package/src/args.ts +83 -0
- package/src/cli.ts +685 -0
- package/src/engine/cache.ts +150 -0
- package/src/engine/compare.ts +563 -0
- package/src/engine/config.ts +502 -0
- package/src/engine/grader.ts +149 -0
- package/src/engine/json-assert.ts +277 -0
- package/src/engine/judge.ts +388 -0
- package/src/engine/receipt.ts +150 -0
- package/src/engine/render.ts +59 -0
- package/src/engine/report.ts +49 -0
- package/src/engine/sandbox.ts +97 -0
- package/src/engine/skill-install.ts +112 -0
- package/src/engine/stats.ts +41 -0
- package/src/prompt.ts +16 -0
- package/src/runner/claude-p.ts +156 -0
- package/src/runner/index.ts +31 -0
- package/src/runner/openai-compat.ts +228 -0
- package/src/types.ts +65 -0
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)
|