@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/SPEC.md ADDED
@@ -0,0 +1,314 @@
1
+ # promptdiff - Design Spec
2
+
3
+ Status: early implementation. The original spike proved the `claude -p`
4
+ mechanism; the current repo now contains a real `run` command, a scenario-driven
5
+ `compare` command, deterministic graders, sandbox setup, timeout and budget
6
+ bounds, pluggable runners (headless Claude Code and OpenAI-compatible
7
+ endpoints), and tests for the core local behavior.
8
+
9
+ ## 1. Problem
10
+
11
+ AI agent pipelines generate proposed improvements to prompts, skills, and
12
+ enforcement hooks. The chronic failure mode is that proposed edits are shipped
13
+ on faith or never shipped at all because there is no cheap proof that the edit
14
+ changes behavior without damaging scenarios that already worked.
15
+
16
+ `promptdiff` is the proof step between "we think this skill edit helps" and
17
+ "ship it." It does not collect findings, rank recurrence, or manage tuning
18
+ rounds. Those can live in a larger system. This repo focuses on the eval harness.
19
+
20
+ ## 2. Eval Altitudes
21
+
22
+ The eval method should match the change being tested:
23
+
24
+ | Change altitude | Eval method | Cost |
25
+ |---|---|---|
26
+ | Enforcement hook | Deterministic unit test | 1 run, near-free |
27
+ | Skill or agent prompt text | N-run stochastic A/B over pass rates | N x arms x per-run cost |
28
+
29
+ `promptdiff` targets the second row. It runs one agent with baseline skill text
30
+ and one agent with proposed skill text against the same scenario set.
31
+
32
+ ## 3. Runners
33
+
34
+ Model access goes through a small `Runner` interface: system prompt + user
35
+ prompt + bounds in, `RunResult` (output text, cost, turns, duration, models)
36
+ out. Each runner declares capabilities:
37
+
38
+ - `sandboxTools`: the runner executes tools inside the sandbox cwd (needed for
39
+ artifact mode, command graders against agent-written files, and any
40
+ non-empty tools list)
41
+ - `skillRegistry`: the runner has a harness-managed skill registry and an
42
+ appendable default system prompt (needed for install delivery)
43
+ - `images`: the runner can attach image files to the user message (needed for
44
+ scenarios with `images` / `run --image`)
45
+
46
+ The engine validates a scenario's demands against the selected runner's
47
+ capabilities before any paid run, so unsupported combinations fail loudly at
48
+ startup instead of producing a silently tool-less arm. Runners are selected
49
+ with `--runner <name>` or a top-level `"runner"` scenario field.
50
+
51
+ ### 3.1 `claude-p` (default)
52
+
53
+ Shells out to headless Claude Code:
54
+
55
+ ```bash
56
+ claude -p "<fixture work item>" \
57
+ --system-prompt-file <assembled prompt file> \
58
+ --model <model> \
59
+ --output-format json \
60
+ --tools <tools> \
61
+ --max-budget-usd <amount>
62
+ ```
63
+
64
+ The spawned process runs with the per-run sandbox as its actual working
65
+ directory. Extra directories can be granted with `--add-dir`, but they are not
66
+ used as a substitute for sandboxing.
67
+
68
+ The runner captures final output text, `total_cost_usd`, turn count, duration,
69
+ and model usage keys.
70
+
71
+ ### 3.2 `openai`
72
+
73
+ Sends one `POST <baseUrl>/chat/completions` request to any OpenAI-compatible
74
+ endpoint (OpenAI, ollama, vLLM, llama.cpp, OpenRouter, ...). The assembled
75
+ system prompt and the scenario prompt become the `system` and `user` messages.
76
+ `--base-url`/`$OPENAI_BASE_URL` select the server; `$OPENAI_API_KEY` is sent as
77
+ a bearer token when set (local servers need none).
78
+
79
+ Text mode only — no tools, no sandbox execution, no skill registry, so it pairs
80
+ with text graders. These endpoints report tokens, not USD, so pricing is
81
+ user-declared: scenario `pricing` (per-model USD per million input/output
82
+ tokens) or `run --price in,out`. Priced runs compute real cost from response
83
+ `usage`, enforce `maxBudgetUsd` post-hoc (a completed completion cannot be
84
+ aborted mid-request; over-budget runs error rather than retry), and fail
85
+ loudly when a priced endpoint returns no usage. Unpriced runs report 0 —
86
+ true for local servers — and are bounded only by being single completions.
87
+ Raw usage is always preserved in the raw result.
88
+
89
+ Vision: this is the only runner declaring the `images` capability. Scenario
90
+ `images` (or `run --image`) are embedded as base64 data-URI `image_url`
91
+ content parts ahead of the prompt text — jpg/jpeg/png/webp/gif, validated for
92
+ existence at load time. Two scenario fields tune the endpoint: `requestParams`
93
+ merges extra fields into the request body (spread first, so it can never
94
+ clobber `model` or `messages` — pin `temperature` here to keep pass-rate
95
+ deltas about the prompt), and `retries` (default 2) re-attempts transient
96
+ failures — timeouts, connection errors, HTTP 429/5xx — with exponential
97
+ backoff and a per-attempt timeout, so one overloaded-endpoint response cannot
98
+ abort a whole compare. Deterministic failures (other 4xx, malformed
99
+ responses) fail immediately without burning retries. claude-p is excluded on
100
+ purpose: the CLI manages its own transport, and retrying non-zero exits
101
+ would re-run budget aborts.
102
+
103
+ ## 4. A/B Variable
104
+
105
+ Two delivery modes, matching two distinct questions about a skill edit:
106
+
107
+ **`inline` (default) — compliance.** Skill variants are inlined into the system
108
+ prompt instead of being loaded by the Skill tool at runtime:
109
+
110
+ ```text
111
+ system prompt = agent body with frontmatter stripped
112
+ + baseline or proposed skill body with frontmatter stripped
113
+ ```
114
+
115
+ That keeps the comparison controlled. The baseline and proposed arms differ by
116
+ the skill text under test, not by whichever skill version happens to be
117
+ installed on disk. The limitation is the flip side of the control: an inlined
118
+ skill is always in context, so inline evals cannot measure whether the skill
119
+ would have been *invoked* — and frontmatter (including the `description` that
120
+ drives model-invoked triggering) is stripped entirely.
121
+
122
+ **`install` — invocation.** The skill directory is copied, frontmatter intact,
123
+ into `<sandbox>/.claude/skills/<name>`, where headless Claude's project-skill
124
+ discovery registers it. The agent body is delivered with
125
+ `--append-system-prompt` rather than `--system-prompt-file`, because replacing
126
+ the system prompt would strip the harness machinery (skill registry, Skill
127
+ tool) that this mode exists to exercise. Arms differ only by which variant
128
+ directory lands in the per-run sandbox registry. This is the mode for A/B
129
+ testing description/trigger wording — the "does it fire at all" question. It
130
+ requires tools and is incompatible with `--tools ""`.
131
+
132
+ Known contamination source in install mode: same-named user-level skills load
133
+ in both arms; the installer warns when it detects one.
134
+
135
+ **Model as the A/B variable.** The arms can also differ by model, runner, or
136
+ endpoint instead of (or in addition to) skill text. A shared top-level
137
+ `"skills"` array holds the instruction set constant, and the record form of
138
+ `"baseline"`/`"proposed"` carries per-arm `"model"`, `"runner"`, and
139
+ `"baseUrl"` (each falling back to the shared top-level value). Each arm is
140
+ validated against its own runner's capabilities before any paid run, and the
141
+ summary labels each arm with its model (and runner, when they differ).
142
+
143
+ Cross-runner confound: a claude-p arm is agentic — tools, multiple turns —
144
+ while an openai arm is a single completion. Pass rates on text-graded
145
+ scenarios compare meaningfully, but the mechanics differ and cost does not
146
+ compare at all (the openai runner reports $0; the summary notes this when
147
+ exactly one arm is openai).
148
+
149
+ ## 5. Scenario Format
150
+
151
+ A compare file defines:
152
+
153
+ - agent file
154
+ - baseline skill files (or a shared skill set both arms inherit)
155
+ - proposed skill files
156
+ - model (shared, or per arm for model comparisons)
157
+ - run count
158
+ - sandbox root and optional seed workspace
159
+ - target and regression scenarios
160
+ - deterministic graders
161
+
162
+ The first scenario defaults to `target`; later scenarios default to
163
+ `regression`. Explicit `kind` is preferred.
164
+
165
+ Target assertions:
166
+
167
+ 1. baseline must not fully pass, otherwise the gap was not reproduced
168
+ 2. proposed must improve the target pass rate
169
+
170
+ Regression assertions:
171
+
172
+ 1. proposed must not fall below baseline pass rate
173
+
174
+ Every case also carries a two-tailed Fisher exact p for its pass/fail table;
175
+ the summary labels deltas with p > 0.05 as explainable by sampling noise.
176
+ This is a label, not a gate — assertions and exit codes are unchanged, but a
177
+ receipt now says how thin its evidence is. Scenarios may declare
178
+ `productionModel`; arms testing a different model are flagged in the summary.
179
+ `--report ndjson --report-out <file>` appends per-scenario records (arms,
180
+ rates, cost, sampling p, rendered-prompt sha256 hashes) as append-only run
181
+ history. `--receipts <dir>` writes per-scenario receipt files (overwritten
182
+ each run) recording the content hash of every prompt file tested plus the
183
+ verdict — content addressing instead of hand-maintained prompt_version
184
+ strings, so a consuming repo's CI can require a passing receipt for each
185
+ shipped prompt's current hash. Reports are history; receipts are current
186
+ state.
187
+
188
+ `compare` scenarios assert nothing: they exist to report both arms' pass
189
+ rates and the delta, for comparisons (typically model-vs-model) where neither
190
+ direction is claimed in advance.
191
+
192
+ **Measure mode.** `promptdiff measure` runs a single instruction set through
193
+ the same scenario machinery and reports per-case pass rates with no delta
194
+ and no assertions — the "characterize before you change" half of prompt
195
+ testing. It loads the compare format with a single-arm allowance (`skills`
196
+ or `baselineSkills` alone suffices) and always exits 0 on completion.
197
+ Identical-arm compares are not a substitute: at small n they emit
198
+ directional verdicts from pure sampling noise.
199
+
200
+ **Template rendering.** `render.vars` — top-level and/or per scenario, with
201
+ the scenario winning per var — binds `{{name}}` placeholders across the agent
202
+ body, inlined skill text, and scenario prompts. This is what lets scenarios
203
+ point at production prompt files (which are full of pipeline placeholders)
204
+ instead of hand-rendered copies that drift. Values resolve file-first
205
+ relative to the scenario file and are read at load time; anything that isn't
206
+ an existing file is a literal. Rendering is opt-in and strict: with any
207
+ `render` block present, unbound placeholders abort before any paid run;
208
+ without one, braces pass through untouched. Substituted content is never
209
+ re-scanned, so fixtures containing braces neither expand recursively nor
210
+ false-positive the unbound check. Incompatible with install delivery, which
211
+ copies skill files verbatim.
212
+
213
+ ## 6. Grading
214
+
215
+ Prefer deterministic graders over LLM judges.
216
+
217
+ Text graders inspect the run's final output. JSON graders parse that output —
218
+ the last balanced JSON value when prose surrounds it, since reasoning models
219
+ narrate around their answer — and check `assert` path assertions of the form
220
+ `<path> <op> <literal>` (ops `==` `!=` `>` `>=` `<` `<=` `contains`; `[*]` is
221
+ existential: any element may satisfy the assertion, including for `!=`).
222
+ Assertion grammar is validated at config load, before any paid run; missing
223
+ paths and type mismatches at grade time fail the assertion with a message,
224
+ never throw. Like text graders, json graders demand no sandbox tools and work
225
+ on every runner. Command graders run inside the
226
+ sandbox after the model invocation and check the exit code of a local command
227
+ such as `bun test`, `go test ./...`, or a fixture-specific script. The final
228
+ output is also written into the sandbox and exposed to command graders as
229
+ `$PROMPTDIFF_OUTPUT_FILE`, so completion-only runs can be command-graded
230
+ (scenario `mode: "text"` keeps the tools demand at zero).
231
+
232
+ LLM judges are implemented, WITH mandatory calibration, for judgments that
233
+ cannot be expressed as local checks (semantic and style rubrics that regex
234
+ both under- and over-catches). The design premise is that an uncalibrated
235
+ judge is worse than the regex it replaces — same wrongness, more confidence,
236
+ higher cost — so judge graders refuse to grade until proven:
237
+
238
+ - A judge grader is `{ "type": "judge", "rubric": <markdown file>, "model":
239
+ <judge model>, "runner": <claude-p|openai>, "baseUrl"?, "minAccuracy"?
240
+ (default 0.9) }`. The rubric becomes the judge's system prompt verbatim,
241
+ plus a fixed harness instruction demanding one JSON verdict object; the
242
+ graded output is the user prompt. The judge model is explicit on purpose
243
+ and never defaults to the arm's model (self-grading bias). The last
244
+ balanced JSON object in the reply wins (reasoning models add prose); no
245
+ valid verdict fails the graded run, never passes it.
246
+ - Labeled fixtures live in a sibling directory,
247
+ `<rubric-stem>.fixtures/pass/*.md` (outputs the judge must call clean) and
248
+ `.../fail/*.md` (outputs it must flag). `promptdiff calibrate` runs the
249
+ judge over every fixture and writes `<rubric>.calibration.json` next to
250
+ the rubric — committable, keyed by rubric content hash.
251
+ - The gate: at compare/measure startup, before any paid run, every judge
252
+ grader must have a calibration record that exists, matches the current
253
+ rubric sha256 and the spec's model/runner, and clears `minAccuracy` on
254
+ BOTH classes. Per-class bars on purpose: a judge that passes everything is
255
+ 100% on the pass class and 0% on the fail class; overall accuracy hides it.
256
+ - v1 judges are absolute (grade one output against the rubric); pairwise
257
+ comparison is deferred. The calibration gate is the escalation signal: a
258
+ rubric whose absolute judge cannot clear the bar is the case for pairwise.
259
+
260
+ A judge adds one billed model call per graded run (its cost is added to the
261
+ run's cost), so deterministic graders remain the default choice.
262
+
263
+ ## 7. Architecture
264
+
265
+ ```text
266
+ promptdiff # Bun executable shim
267
+ src/cli.ts # command parsing and user-facing orchestration
268
+ src/args.ts # strict local flag parser
269
+ src/prompt.ts # frontmatter stripping and prompt assembly
270
+ src/runner/ # provider-coupled runner code
271
+ src/engine/ # compare loop, config loading, graders, sandbox lifecycle
272
+ test/ # Bun tests
273
+ ```
274
+
275
+ The compare engine depends only on the runner interface and its declared
276
+ capabilities; it never imports provider SDKs or provider-specific code.
277
+ `src/runner/index.ts` is the registry that maps runner names to
278
+ implementations.
279
+
280
+ Baseline arms are usually frozen by construction, so `compare --cache`
281
+ (opt-in; results under `--cache-dir`, default `.promptdiff/cache`) reuses
282
+ recorded baseline-arm results instead of re-paying for them on every
283
+ iteration of the proposed prompt. `src/engine/cache.ts` keys each case on a
284
+ sha256 over everything that could change the outcome — rendered baseline
285
+ system prompt, scenario prompt, baseline model/runner/baseUrl, run count,
286
+ tools/mode/delivery, grader spec, image contents, and a deterministic tree
287
+ hash of the sandbox seed (plus the baseline skill trees under install
288
+ delivery, where skill text never enters the system prompt). Hits are real
289
+ recorded ArmSummaries, marked `(cached)` on the summary; the proposed arm
290
+ always runs fresh. Deleting the cache directory busts it.
291
+
292
+ ## 8. Current Limitations
293
+
294
+ - Pass-rate assertions are intentionally simple. They are good enough to catch
295
+ decisive effects at small N, but there is no statistical test yet.
296
+ - Scenario authoring is manual JSON. A future tuning loop should generate these
297
+ files from accepted findings.
298
+ - Command graders run trusted local commands from scenario files. Do not run
299
+ untrusted scenario files.
300
+ - The openai runner is a single completion per run: no tool use, so it can only
301
+ answer text-graded questions. Tool-using evals on non-Claude models would
302
+ need an agentic runner (e.g. wrapping another agent CLI).
303
+ - Fixture coverage remains the bottleneck. Missing fixtures mean missing
304
+ regression protection.
305
+
306
+ ## 9. Decisions
307
+
308
+ - Standalone Bun repo rather than embedding in a larger mission runner.
309
+ - Claude Code `-p` runner first; OpenAI-compatible endpoints second. Runner
310
+ capabilities are validated up front rather than degraded silently.
311
+ - Inline skills for variant control.
312
+ - Deterministic graders first; LLM judges only behind a mandatory calibration
313
+ gate (absolute mode in v1, pairwise deferred).
314
+ - Per-run sandbox cwd, timeout, and budget bounds are mandatory for paid runs.
package/package.json ADDED
@@ -0,0 +1,54 @@
1
+ {
2
+ "name": "@theaiteam/promptdiff",
3
+ "version": "1.0.0-rc.1",
4
+ "description": "A/B test harness for LLM prompt and skill changes — headless Claude Code or any OpenAI-compatible endpoint.",
5
+ "author": "Josh Owens <josh@theaiteam.dev>",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/queso/promptdiff.git"
9
+ },
10
+ "homepage": "https://github.com/queso/promptdiff#readme",
11
+ "bugs": {
12
+ "url": "https://github.com/queso/promptdiff/issues"
13
+ },
14
+ "type": "module",
15
+ "packageManager": "bun@1.3.11",
16
+ "bin": {
17
+ "promptdiff": "./promptdiff"
18
+ },
19
+ "files": [
20
+ "promptdiff",
21
+ "src",
22
+ "README.md",
23
+ "SPEC.md",
24
+ "CHANGELOG.md"
25
+ ],
26
+ "scripts": {
27
+ "eval": "./promptdiff",
28
+ "test": "bun test",
29
+ "typecheck": "tsc --noEmit",
30
+ "check": "bun test && tsc --noEmit",
31
+ "prepublishOnly": "bun run check"
32
+ },
33
+ "engines": {
34
+ "bun": ">=1.0.0"
35
+ },
36
+ "keywords": [
37
+ "ai",
38
+ "agents",
39
+ "evals",
40
+ "prompts",
41
+ "llm",
42
+ "claude",
43
+ "openai",
44
+ "bun"
45
+ ],
46
+ "publishConfig": {
47
+ "access": "public"
48
+ },
49
+ "devDependencies": {
50
+ "@types/bun": "latest",
51
+ "typescript": "latest"
52
+ },
53
+ "license": "MIT"
54
+ }
package/promptdiff ADDED
@@ -0,0 +1,4 @@
1
+ #!/usr/bin/env bun
2
+ import { main } from "./src/cli";
3
+
4
+ process.exitCode = await main(process.argv.slice(2));
package/src/args.ts ADDED
@@ -0,0 +1,83 @@
1
+ export class CliError extends Error {
2
+ readonly exitCode = 2;
3
+ }
4
+
5
+ export type FlagSpec = {
6
+ arity: "none" | "one";
7
+ repeat?: boolean;
8
+ };
9
+
10
+ export type FlagSpecs = Record<string, FlagSpec>;
11
+
12
+ export interface ParsedArgs {
13
+ one(name: string): string | undefined;
14
+ many(name: string): string[];
15
+ has(name: string): boolean;
16
+ number(name: string, defaultValue: number): number;
17
+ }
18
+
19
+ export function parseArgs(argv: string[], specs: FlagSpecs): ParsedArgs {
20
+ const out: Record<string, string[]> = {};
21
+
22
+ for (let i = 0; i < argv.length; i += 1) {
23
+ const token = argv[i];
24
+ if (!token.startsWith("--") || token === "--") {
25
+ throw new CliError(`unexpected positional argument: ${token}`);
26
+ }
27
+
28
+ const raw = token.slice(2);
29
+ const eq = raw.indexOf("=");
30
+ const name = eq === -1 ? raw : raw.slice(0, eq);
31
+ const inlineValue = eq === -1 ? undefined : raw.slice(eq + 1);
32
+ const spec = specs[name];
33
+ if (!spec) {
34
+ throw new CliError(`unknown flag: --${name}`);
35
+ }
36
+
37
+ if (!spec.repeat && out[name]?.length) {
38
+ throw new CliError(`flag cannot be repeated: --${name}`);
39
+ }
40
+
41
+ if (spec.arity === "none") {
42
+ if (inlineValue !== undefined) {
43
+ throw new CliError(`flag does not take a value: --${name}`);
44
+ }
45
+ out[name] ??= [];
46
+ out[name].push("true");
47
+ continue;
48
+ }
49
+
50
+ let value = inlineValue;
51
+ if (value === undefined) {
52
+ i += 1;
53
+ value = argv[i];
54
+ }
55
+ if (value === undefined || value.startsWith("--")) {
56
+ throw new CliError(`missing value for --${name}`);
57
+ }
58
+
59
+ out[name] ??= [];
60
+ out[name].push(value);
61
+ }
62
+
63
+ return {
64
+ one(name: string) {
65
+ return out[name]?.[0];
66
+ },
67
+ many(name: string) {
68
+ return out[name] ?? [];
69
+ },
70
+ has(name: string) {
71
+ return Object.hasOwn(out, name);
72
+ },
73
+ number(name: string, defaultValue: number) {
74
+ const raw = out[name]?.[0];
75
+ if (raw === undefined) return defaultValue;
76
+ const parsed = Number(raw);
77
+ if (!Number.isFinite(parsed)) {
78
+ throw new CliError(`--${name} must be a number`);
79
+ }
80
+ return parsed;
81
+ },
82
+ };
83
+ }