@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/src/cli.ts ADDED
@@ -0,0 +1,685 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+ import { CliError, parseArgs, type FlagSpecs } from "./args";
4
+ import { loadCompareConfig, type ArmConfig, type CompareConfig, type CompareOverrides } from "./engine/config";
5
+ import { formatCompareSummary, formatMeasureSummary, runCompare, runMeasure } from "./engine/compare";
6
+ import { prepareSandbox } from "./engine/sandbox";
7
+ import { installSkills, type Delivery } from "./engine/skill-install";
8
+ import { renderStrict, resolveRenderVars, type RenderVars } from "./engine/render";
9
+ import { appendNdjsonReport } from "./engine/report";
10
+ import { buildCompareReceipts, buildMeasureReceipts, writeReceipts } from "./engine/receipt";
11
+ import { formatCalibrationReport, runCalibration } from "./engine/judge";
12
+ import { assembleSystemPrompt } from "./prompt";
13
+ import { createRunner, RUNNER_NAMES, type RunnerName } from "./runner";
14
+ import type { ModelPricing } from "./runner/openai-compat";
15
+ import type { RunMode, Runner, RunnerRunOptions } from "./types";
16
+
17
+ const DEFAULT_TIMEOUT_MS = 600_000;
18
+ const DEFAULT_MAX_BUDGET_USD = 1;
19
+
20
+ const runSpecs: FlagSpecs = {
21
+ agent: { arity: "one" },
22
+ skill: { arity: "one", repeat: true },
23
+ delivery: { arity: "one" },
24
+ runner: { arity: "one" },
25
+ "base-url": { arity: "one" },
26
+ model: { arity: "one" },
27
+ prompt: { arity: "one" },
28
+ "prompt-file": { arity: "one" },
29
+ image: { arity: "one", repeat: true },
30
+ var: { arity: "one", repeat: true },
31
+ price: { arity: "one" },
32
+ mode: { arity: "one" },
33
+ sandbox: { arity: "one" },
34
+ seed: { arity: "one" },
35
+ "add-dir": { arity: "one", repeat: true },
36
+ tools: { arity: "one" },
37
+ "timeout-ms": { arity: "one" },
38
+ "max-budget-usd": { arity: "one" },
39
+ "keep-sandbox": { arity: "none" },
40
+ "clean-sandbox": { arity: "none" },
41
+ };
42
+
43
+ const compareSpecs: FlagSpecs = {
44
+ scenario: { arity: "one" },
45
+ agent: { arity: "one" },
46
+ baseline: { arity: "one", repeat: true },
47
+ proposed: { arity: "one", repeat: true },
48
+ "baseline-skill": { arity: "one", repeat: true },
49
+ "proposed-skill": { arity: "one", repeat: true },
50
+ delivery: { arity: "one" },
51
+ runner: { arity: "one" },
52
+ "baseline-runner": { arity: "one" },
53
+ "proposed-runner": { arity: "one" },
54
+ "base-url": { arity: "one" },
55
+ model: { arity: "one" },
56
+ "baseline-model": { arity: "one" },
57
+ "proposed-model": { arity: "one" },
58
+ runs: { arity: "one" },
59
+ mode: { arity: "one" },
60
+ sandbox: { arity: "one" },
61
+ seed: { arity: "one" },
62
+ "add-dir": { arity: "one", repeat: true },
63
+ tools: { arity: "one" },
64
+ "timeout-ms": { arity: "one" },
65
+ "max-budget-usd": { arity: "one" },
66
+ "keep-sandbox": { arity: "none" },
67
+ report: { arity: "one" },
68
+ "report-out": { arity: "one" },
69
+ receipts: { arity: "one" },
70
+ cache: { arity: "none" },
71
+ "cache-dir": { arity: "one" },
72
+ };
73
+
74
+ export async function main(argv: string[]): Promise<number> {
75
+ try {
76
+ const [command, ...rest] = argv;
77
+ switch (command) {
78
+ case undefined:
79
+ case "--help":
80
+ case "-h":
81
+ case "help":
82
+ console.log(generalUsage());
83
+ return command === undefined ? 2 : 0;
84
+ case "--version":
85
+ console.log(packageVersion());
86
+ return 0;
87
+ case "run":
88
+ if (isHelp(rest)) {
89
+ console.log(runUsage());
90
+ return 0;
91
+ }
92
+ await cmdRun(rest);
93
+ return 0;
94
+ case "compare":
95
+ if (isHelp(rest)) {
96
+ console.log(compareUsage());
97
+ return 0;
98
+ }
99
+ return await cmdCompare(rest);
100
+ case "measure":
101
+ if (isHelp(rest)) {
102
+ console.log(measureUsage());
103
+ return 0;
104
+ }
105
+ await cmdMeasure(rest);
106
+ return 0;
107
+ case "calibrate":
108
+ if (isHelp(rest)) {
109
+ console.log(calibrateUsage());
110
+ return 0;
111
+ }
112
+ await cmdCalibrate(rest);
113
+ return 0;
114
+ default:
115
+ throw new CliError(generalUsage());
116
+ }
117
+ } catch (error) {
118
+ if (error instanceof CliError) {
119
+ console.error(error.message);
120
+ return error.exitCode;
121
+ }
122
+ console.error(error instanceof Error ? error.message : String(error));
123
+ return 1;
124
+ }
125
+ }
126
+
127
+ function isHelp(argv: string[]): boolean {
128
+ return argv.length === 1 && (argv[0] === "--help" || argv[0] === "-h");
129
+ }
130
+
131
+ function packageVersion(): string {
132
+ const pkg = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")) as { version: string };
133
+ return pkg.version;
134
+ }
135
+
136
+ async function cmdRun(argv: string[]): Promise<void> {
137
+ const args = parseArgs(argv, runSpecs);
138
+ const agent = args.one("agent");
139
+ const model = args.one("model");
140
+ if (!agent || !model) {
141
+ throw new CliError(runUsage());
142
+ }
143
+
144
+ const prompt = promptFromArgs(args.one("prompt"), args.one("prompt-file"));
145
+ const delivery = deliveryFromString(args.one("delivery") ?? "inline");
146
+ const mode = modeFromString(args.one("mode") ?? (delivery === "install" ? "artifact" : "text"));
147
+ const tools = args.one("tools") ?? (delivery === "install" ? "default" : defaultTools(mode));
148
+ if (delivery === "install" && tools === "") {
149
+ throw new CliError('--delivery install needs tools enabled: skills trigger via the Skill tool, so --tools "" can never fire them');
150
+ }
151
+ const runner = runnerFromArgs(args.one("runner"), args.one("base-url"), args.one("price"));
152
+ if (delivery === "install" && !runner.capabilities.skillRegistry) {
153
+ throw new CliError(`--delivery install needs a runner with a skill registry; runner "${runner.name}" has none (use claude-p)`);
154
+ }
155
+ if (tools !== "" && !runner.capabilities.sandboxTools) {
156
+ throw new CliError(`runner "${runner.name}" is text-only — artifact mode and tools need claude-p (or pass --tools "")`);
157
+ }
158
+ const images = args.many("image");
159
+ if (images.length > 0 && !runner.capabilities.images) {
160
+ throw new CliError(`runner "${runner.name}" cannot attach images — use --runner openai with a vision model`);
161
+ }
162
+ const renderVars = varsFromFlags(args.many("var"));
163
+ if (renderVars !== undefined && delivery === "install") {
164
+ throw new CliError("--var applies to inlined prompt text; --delivery install copies skill files verbatim, so placeholders cannot be bound");
165
+ }
166
+ const keepSandbox = args.has("keep-sandbox") || (mode === "artifact" && !args.has("clean-sandbox"));
167
+ const sandbox = prepareSandbox({
168
+ root: args.one("sandbox") ?? ".promptdiff/run",
169
+ seed: args.one("seed"),
170
+ prefix: "run",
171
+ keep: keepSandbox,
172
+ });
173
+
174
+ try {
175
+ const assembled = assembleSystemPrompt(agent, delivery === "install" ? [] : args.many("skill"));
176
+ const varHint = "bind them with --var name=value";
177
+ const systemPrompt = renderVars === undefined ? assembled : renderStrict(assembled, renderVars, "system prompt", varHint);
178
+ const userPrompt = renderVars === undefined ? prompt : renderStrict(prompt, renderVars, "prompt", varHint);
179
+ if (delivery === "install") {
180
+ const { installed, warnings } = installSkills(args.many("skill"), sandbox.dir);
181
+ console.error(
182
+ `[promptdiff] installed skills: ${installed.map((skill) => skill.name).join(", ") || "(none)"}`,
183
+ );
184
+ for (const warning of warnings) {
185
+ console.error(`[promptdiff] WARNING: ${warning}`);
186
+ }
187
+ }
188
+ const runOptions: RunnerRunOptions = {
189
+ systemPrompt,
190
+ systemPromptMode: delivery === "install" ? "append" : "replace",
191
+ userPrompt,
192
+ images,
193
+ model,
194
+ cwd: sandbox.dir,
195
+ addDirs: args.many("add-dir"),
196
+ tools,
197
+ timeoutMs: args.number("timeout-ms", DEFAULT_TIMEOUT_MS),
198
+ maxBudgetUsd: args.number("max-budget-usd", DEFAULT_MAX_BUDGET_USD),
199
+ };
200
+
201
+ console.error(
202
+ delivery === "install"
203
+ ? `[promptdiff] appended agent prompt: ${systemPrompt.length} chars (skills installed, not inlined)`
204
+ : `[promptdiff] system prompt: ${systemPrompt.length} chars (agent + ${args.many("skill").length} skill(s))`,
205
+ );
206
+ console.error(`[promptdiff] sandbox cwd: ${sandbox.dir}`);
207
+
208
+ const result = await runner.run(runOptions);
209
+ console.log("\n--- OUTPUT ---\n" + result.output);
210
+ console.log(
211
+ `\n--- $${result.costUsd.toFixed(4)} | ${result.turns} turns | ${(result.durationMs / 1000).toFixed(1)}s | models: ${result.models.join(", ")} ---`,
212
+ );
213
+ if (keepSandbox) {
214
+ console.log(`--- sandbox kept: ${sandbox.dir} ---`);
215
+ }
216
+ } finally {
217
+ sandbox.cleanup();
218
+ }
219
+ }
220
+
221
+ const measureSpecs: FlagSpecs = {
222
+ scenario: { arity: "one" },
223
+ agent: { arity: "one" },
224
+ skill: { arity: "one", repeat: true },
225
+ model: { arity: "one" },
226
+ runner: { arity: "one" },
227
+ "base-url": { arity: "one" },
228
+ runs: { arity: "one" },
229
+ mode: { arity: "one" },
230
+ sandbox: { arity: "one" },
231
+ seed: { arity: "one" },
232
+ "add-dir": { arity: "one", repeat: true },
233
+ tools: { arity: "one" },
234
+ "timeout-ms": { arity: "one" },
235
+ "max-budget-usd": { arity: "one" },
236
+ "keep-sandbox": { arity: "none" },
237
+ receipts: { arity: "one" },
238
+ };
239
+
240
+ async function cmdMeasure(argv: string[]): Promise<void> {
241
+ const args = parseArgs(argv, measureSpecs);
242
+ const scenario = args.one("scenario");
243
+ if (!scenario) {
244
+ throw new CliError(measureUsage());
245
+ }
246
+
247
+ const skills = args.many("skill");
248
+ const overrides: CompareOverrides = {
249
+ agent: args.one("agent"),
250
+ baselineSkills: skills.length > 0 ? skills : undefined,
251
+ // measure exercises the baseline arm only; mirroring keeps validation happy.
252
+ proposedSkills: skills.length > 0 ? skills : undefined,
253
+ model: args.one("model"),
254
+ runner: args.one("runner") ? runnerNameFromString(args.one("runner")) : undefined,
255
+ baseUrl: args.one("base-url"),
256
+ runs: args.has("runs") ? args.number("runs", 0) : undefined,
257
+ timeoutMs: args.has("timeout-ms") ? args.number("timeout-ms", DEFAULT_TIMEOUT_MS) : undefined,
258
+ maxBudgetUsd: args.has("max-budget-usd") ? args.number("max-budget-usd", DEFAULT_MAX_BUDGET_USD) : undefined,
259
+ mode: args.one("mode") ? modeFromString(args.one("mode")) : undefined,
260
+ tools: args.one("tools"),
261
+ addDirs: args.many("add-dir").length ? args.many("add-dir") : undefined,
262
+ sandboxRoot: args.one("sandbox"),
263
+ sandboxSeed: args.one("seed"),
264
+ keepSandbox: args.has("keep-sandbox") ? true : undefined,
265
+ };
266
+
267
+ const config = loadCompareConfig(scenario, overrides, { singleArm: true });
268
+ const summary = await runMeasure({
269
+ config,
270
+ runner: armRunner(config.arms.baseline, config),
271
+ onProgress: (message) => console.error(`[promptdiff] ${message}`),
272
+ });
273
+
274
+ const receiptsDir = args.one("receipts");
275
+ if (receiptsDir) {
276
+ const written = writeReceipts(receiptsDir, buildMeasureReceipts(summary, config, new Date().toISOString()));
277
+ console.error(`[promptdiff] wrote ${written.length} receipt(s) to ${receiptsDir}`);
278
+ }
279
+
280
+ console.log(formatMeasureSummary(summary));
281
+ }
282
+
283
+ const calibrateSpecs: FlagSpecs = {
284
+ rubric: { arity: "one" },
285
+ model: { arity: "one" },
286
+ runner: { arity: "one" },
287
+ "base-url": { arity: "one" },
288
+ "timeout-ms": { arity: "one" },
289
+ "max-budget-usd": { arity: "one" },
290
+ };
291
+
292
+ async function cmdCalibrate(argv: string[]): Promise<void> {
293
+ const args = parseArgs(argv, calibrateSpecs);
294
+ const rubric = args.one("rubric");
295
+ const model = args.one("model");
296
+ if (!rubric || !model) {
297
+ throw new CliError(calibrateUsage());
298
+ }
299
+
300
+ const result = await runCalibration({
301
+ rubric: resolve(rubric),
302
+ model,
303
+ runner: args.one("runner") ? runnerNameFromString(args.one("runner")) : "claude-p",
304
+ baseUrl: args.one("base-url"),
305
+ timeoutMs: args.number("timeout-ms", DEFAULT_TIMEOUT_MS),
306
+ maxBudgetUsd: args.number("max-budget-usd", DEFAULT_MAX_BUDGET_USD),
307
+ onProgress: (message) => console.error(`[promptdiff] ${message}`),
308
+ });
309
+
310
+ // Always exit 0: calibrate measures, the compare/measure gate enforces.
311
+ console.log(formatCalibrationReport(result));
312
+ }
313
+
314
+ async function cmdCompare(argv: string[]): Promise<number> {
315
+ const args = parseArgs(argv, compareSpecs);
316
+ const scenario = args.one("scenario");
317
+ if (!scenario) {
318
+ throw new CliError(compareUsage());
319
+ }
320
+ // Validate report flags before any paid run, not after.
321
+ const report = args.one("report");
322
+ const reportOut = args.one("report-out");
323
+ if (report !== undefined && report !== "ndjson") {
324
+ throw new CliError('--report supports only "ndjson"');
325
+ }
326
+ if (report === "ndjson" && !reportOut) {
327
+ throw new CliError("--report ndjson requires --report-out <file>");
328
+ }
329
+ if (reportOut && report === undefined) {
330
+ throw new CliError("--report-out requires --report ndjson");
331
+ }
332
+ // Caching is opt-in: a bare --cache-dir must not silently enable it.
333
+ if (args.one("cache-dir") !== undefined && !args.has("cache")) {
334
+ throw new CliError("--cache-dir requires --cache");
335
+ }
336
+ const cache = args.has("cache") ? { dir: args.one("cache-dir") ?? ".promptdiff/cache" } : undefined;
337
+
338
+ const overrides: CompareOverrides = {
339
+ agent: args.one("agent"),
340
+ baselineSkills: coalesceMany(args.many("baseline-skill"), args.many("baseline")),
341
+ proposedSkills: coalesceMany(args.many("proposed-skill"), args.many("proposed")),
342
+ delivery: args.one("delivery") ? deliveryFromString(args.one("delivery")) : undefined,
343
+ runner: args.one("runner") ? runnerNameFromString(args.one("runner")) : undefined,
344
+ baselineRunner: args.one("baseline-runner") ? runnerNameFromString(args.one("baseline-runner")) : undefined,
345
+ proposedRunner: args.one("proposed-runner") ? runnerNameFromString(args.one("proposed-runner")) : undefined,
346
+ baseUrl: args.one("base-url"),
347
+ model: args.one("model"),
348
+ baselineModel: args.one("baseline-model"),
349
+ proposedModel: args.one("proposed-model"),
350
+ runs: args.has("runs") ? args.number("runs", 0) : undefined,
351
+ timeoutMs: args.has("timeout-ms") ? args.number("timeout-ms", DEFAULT_TIMEOUT_MS) : undefined,
352
+ maxBudgetUsd: args.has("max-budget-usd") ? args.number("max-budget-usd", DEFAULT_MAX_BUDGET_USD) : undefined,
353
+ mode: args.one("mode") ? modeFromString(args.one("mode")) : undefined,
354
+ tools: args.one("tools"),
355
+ addDirs: args.many("add-dir").length ? args.many("add-dir") : undefined,
356
+ sandboxRoot: args.one("sandbox"),
357
+ sandboxSeed: args.one("seed"),
358
+ keepSandbox: args.has("keep-sandbox") ? true : undefined,
359
+ };
360
+
361
+ const config = loadCompareConfig(scenario, overrides);
362
+ const summary = await runCompare({
363
+ config,
364
+ runners: {
365
+ baseline: armRunner(config.arms.baseline, config),
366
+ proposed: armRunner(config.arms.proposed, config),
367
+ },
368
+ onProgress: (message) => console.error(`[promptdiff] ${message}`),
369
+ cache,
370
+ });
371
+
372
+ // History is appended before the exit code is decided — failed comparisons
373
+ // belong in the record just as much as passing ones.
374
+ if (report === "ndjson" && reportOut) {
375
+ const count = appendNdjsonReport(reportOut, summary);
376
+ console.error(`[promptdiff] appended ${count} record(s) to ${reportOut}`);
377
+ }
378
+ // Receipts too: a failing receipt is what stops the prompt from shipping.
379
+ const receiptsDir = args.one("receipts");
380
+ if (receiptsDir) {
381
+ const written = writeReceipts(receiptsDir, buildCompareReceipts(summary, config, new Date().toISOString()));
382
+ console.error(`[promptdiff] wrote ${written.length} receipt(s) to ${receiptsDir}`);
383
+ }
384
+
385
+ console.log(formatCompareSummary(summary));
386
+ return summary.failedAssertions.length > 0 ? 1 : 0;
387
+ }
388
+
389
+ function promptFromArgs(prompt: string | undefined, promptFile: string | undefined): string {
390
+ if (prompt && promptFile) {
391
+ throw new CliError("pass only one of --prompt or --prompt-file");
392
+ }
393
+ if (prompt) return prompt;
394
+ if (promptFile) return readFileSync(promptFile, "utf8");
395
+ throw new CliError(runUsage());
396
+ }
397
+
398
+ function modeFromString(value: string | undefined): RunMode {
399
+ if (value === "text" || value === "artifact") return value;
400
+ throw new CliError("--mode must be either text or artifact");
401
+ }
402
+
403
+ function deliveryFromString(value: string | undefined): Delivery {
404
+ if (value === "inline" || value === "install") return value;
405
+ throw new CliError("--delivery must be either inline or install");
406
+ }
407
+
408
+ export function varsFromFlags(entries: string[], baseDir = process.cwd()): RenderVars | undefined {
409
+ if (entries.length === 0) return undefined;
410
+ const raw: Record<string, unknown> = Object.create(null) as Record<string, unknown>;
411
+ for (const entry of entries) {
412
+ const eq = entry.indexOf("=");
413
+ if (eq <= 0) {
414
+ throw new CliError(`--var must be name=value (value is a file path or literal): ${entry}`);
415
+ }
416
+ const name = entry.slice(0, eq);
417
+ // A silently ignored duplicate is exactly the kind of surprise strict
418
+ // rendering exists to prevent.
419
+ if (Object.hasOwn(raw, name)) {
420
+ throw new CliError(`--var ${name} given more than once`);
421
+ }
422
+ raw[name] = entry.slice(eq + 1);
423
+ }
424
+ return resolveRenderVars(raw, baseDir, "--var");
425
+ }
426
+
427
+ function runnerNameFromString(value: string | undefined): RunnerName {
428
+ if (value === "claude-p" || value === "openai") return value;
429
+ throw new CliError(`--runner must be one of: ${RUNNER_NAMES.join(", ")}`);
430
+ }
431
+
432
+ function runnerFromArgs(name: string | undefined, baseUrl: string | undefined, price: string | undefined): Runner {
433
+ const runnerName = name === undefined ? "claude-p" : runnerNameFromString(name);
434
+ if (price !== undefined && runnerName !== "openai") {
435
+ throw new CliError("--price applies to the openai runner; claude-p prices itself");
436
+ }
437
+ return createRunner(runnerName, { baseUrl, pricing: price === undefined ? undefined : priceFromFlag(price) });
438
+ }
439
+
440
+ /** "--price 0.15,0.60" → USD per million input,output tokens. */
441
+ function priceFromFlag(value: string): ModelPricing {
442
+ const [input, output, extra] = value.split(",").map((part) => Number(part));
443
+ if (extra !== undefined || !Number.isFinite(input) || !Number.isFinite(output) || input < 0 || output < 0) {
444
+ throw new CliError(`--price must be <input-usd-per-M>,<output-usd-per-M>: ${value}`);
445
+ }
446
+ return { input, output };
447
+ }
448
+
449
+ function armRunner(arm: ArmConfig, config: CompareConfig): Runner {
450
+ return createRunner(arm.runner, {
451
+ baseUrl: arm.baseUrl,
452
+ retries: config.retries,
453
+ pricing: config.pricing?.[arm.model],
454
+ });
455
+ }
456
+
457
+ function defaultTools(mode: RunMode): string {
458
+ return mode === "text" ? "" : "default";
459
+ }
460
+
461
+ function coalesceMany(primary: string[], fallback: string[]): string[] | undefined {
462
+ if (primary.length > 0) return primary;
463
+ if (fallback.length > 0) return fallback;
464
+ return undefined;
465
+ }
466
+
467
+ function runUsage(): string {
468
+ return [
469
+ "usage: promptdiff run --agent <file.md> --model <model> (--prompt <text>|--prompt-file <file>) [flags]",
470
+ "",
471
+ "Runs one bounded model invocation. Use this to inspect whether one agent +",
472
+ "skill set behaves roughly as expected before promoting the fixture to compare.",
473
+ "",
474
+ "flags: --skill <SKILL.md|dir>... --delivery <inline|install> --mode <text|artifact>",
475
+ " --runner <claude-p|openai> --base-url <url> --image <file>...",
476
+ " --var <name=value>... --sandbox <dir> --seed <dir>",
477
+ " --tools <tools|default|''> --timeout-ms <ms> --max-budget-usd <usd>",
478
+ "",
479
+ "templates:",
480
+ " --var draft=./fixture.md binds {{draft}} in the agent, skills, and prompt;",
481
+ " values naming an existing file are read as contents, otherwise used as",
482
+ " literals. With any --var set, unbound {{placeholders}} fail before the run.",
483
+ "",
484
+ "runners:",
485
+ " claude-p (default) headless Claude Code — supports tools, artifact mode,",
486
+ " and install delivery",
487
+ " openai single chat completion against any OpenAI-compatible endpoint",
488
+ " (text mode only); --base-url or $OPENAI_BASE_URL picks the server,",
489
+ " $OPENAI_API_KEY is sent when set; --image attaches image files",
490
+ " to the user message for vision models (repeatable);",
491
+ " --price <in>,<out> (USD per million tokens) prices the run from",
492
+ " response usage and makes --max-budget-usd enforce",
493
+ "",
494
+ "modes:",
495
+ " text disables tools with --tools '' and grades only the final output manually",
496
+ " artifact runs the agent in a fresh sandbox cwd with default tools enabled",
497
+ "",
498
+ "delivery:",
499
+ " inline (default) skill bodies are inlined into a replaced system prompt —",
500
+ " controlled compliance testing of skill text the model already sees",
501
+ " install skill dirs are copied to <sandbox>/.claude/skills/<name> with",
502
+ " frontmatter intact and the agent text is appended to the default",
503
+ " system prompt — tests whether the registry description actually",
504
+ " triggers the Skill invocation (implies --mode artifact + tools)",
505
+ ].join("\n");
506
+ }
507
+
508
+ function compareUsage(): string {
509
+ return [
510
+ "usage: promptdiff compare --scenario <scenario.json> [overrides]",
511
+ "",
512
+ "Runs baseline and proposed arms against the same scenarios, then grades",
513
+ "each run deterministically and compares pass rates. The arms may differ by",
514
+ "skill set, by model/runner, or both.",
515
+ "",
516
+ "overrides: --agent <file.md> --baseline <SKILL.md>... --proposed <SKILL.md>...",
517
+ " --delivery <inline|install> (install: skills land in the sandbox",
518
+ " registry with frontmatter intact instead of being inlined)",
519
+ " --runner <claude-p|openai> --base-url <url> (openai: text-graded",
520
+ " scenarios against any OpenAI-compatible endpoint)",
521
+ " --model <model> --runs <n> --sandbox <dir> --keep-sandbox",
522
+ " --baseline-model <m> --proposed-model <m>",
523
+ " --baseline-runner <r> --proposed-runner <r>",
524
+ " --mode <text|artifact> --tools <tools|default|''>",
525
+ " --timeout-ms <ms> --max-budget-usd <usd>",
526
+ " --report ndjson --report-out <file>",
527
+ " --cache [--cache-dir <dir>]",
528
+ "",
529
+ "caching:",
530
+ " --cache reuses recorded baseline-arm results (default dir .promptdiff/cache)",
531
+ " when nothing that could change the outcome changed: rendered prompts, skill",
532
+ " and fixture contents, model/runner, run count, tools/mode/delivery, grader.",
533
+ " Opt-in only; the proposed arm always runs fresh. Delete the dir to bust.",
534
+ "",
535
+ "report:",
536
+ " --report ndjson appends one record per scenario to --report-out: arms,",
537
+ " pass rates, cost, sampling p, prompt hashes, productionModel. Append-only",
538
+ " history that answers \"has this drifted since July?\" without hand notes.",
539
+ "",
540
+ "receipts:",
541
+ " --receipts <dir> writes one <scenario>.receipt.json per scenario",
542
+ " (overwritten each run): per-file sha256 of the agent and every skill,",
543
+ " arm results, and a pass/fail verdict. CI in a consuming repo can then",
544
+ " assert every shipped prompt has a passing receipt for its CURRENT hash —",
545
+ " editing a prompt stales its receipt and names the scenario to re-run.",
546
+ "",
547
+ "model comparison:",
548
+ " hold the skills constant and vary the model per arm. A top-level \"skills\"",
549
+ " array is inherited by both arms, and the record form of \"baseline\"/",
550
+ " \"proposed\" accepts per-arm \"model\", \"runner\", and \"baseUrl\" (each falls",
551
+ " back to the shared top-level value). --baseline-model/--proposed-model and",
552
+ " --baseline-runner/--proposed-runner override per arm from the CLI.",
553
+ "",
554
+ "runs:",
555
+ " scenario-level \"runs\": 5 means 5 baseline runs and 5 proposed runs per case.",
556
+ " --runs <n> overrides the scenario default. A case may also define its own runs.",
557
+ "",
558
+ "graders:",
559
+ " text checks the run's final output with contains, notContains, and regex arrays",
560
+ " json parses the LAST balanced JSON value in the run's output and checks",
561
+ " \"assert\" path assertions: <path> <op> <literal>, e.g.",
562
+ " \"findings.items.length == 0\" or \"items[*].domain contains \\\"correctness\\\"\"",
563
+ " (ops: == != > >= < <= contains; [*] passes if ANY element matches;",
564
+ " works with every runner)",
565
+ " command runs a shell command inside the per-run sandbox and checks exit code",
566
+ " (needs a tool-capable runner: claude-p)",
567
+ " judge an explicit judge model grades the final output against a markdown",
568
+ " rubric: { \"type\": \"judge\", \"rubric\": \"./rubrics/r.md\", \"model\": <m>,",
569
+ " \"runner\": <claude-p|openai>, \"baseUrl\"?, \"minAccuracy\"? (default 0.9) }.",
570
+ " Refuses to grade until `promptdiff calibrate` has proven the judge",
571
+ " against labeled fixtures (see `promptdiff calibrate --help`). Adds",
572
+ " one billed model call per graded run.",
573
+ "",
574
+ "images:",
575
+ " a scenario may set \"images\": [\"photo.jpg\", ...] (paths relative to the",
576
+ " scenario file) to attach images to the user message — openai runner +",
577
+ " vision model only",
578
+ "",
579
+ "pricing:",
580
+ " top-level \"pricing\": { \"gpt-4o-mini\": { \"input\": 0.15, \"output\": 0.60 } }",
581
+ " (USD per million tokens) prices openai arms from response usage, so cost",
582
+ " columns are real and maxBudgetUsd enforces. Unpriced openai arms report $0",
583
+ " (true for local servers). claude-p arms price themselves.",
584
+ "",
585
+ "templates:",
586
+ " top-level \"render\": { \"vars\": { \"draft\": \"./fixtures/a.md\" } } binds",
587
+ " {{draft}} in the agent, inlined skills, and scenario prompts, so scenarios",
588
+ " can point at production prompt files with placeholders. Values naming an",
589
+ " existing file (relative to the scenario) are read as contents, otherwise",
590
+ " used as literals. Scenarios may add their own \"render\" (scenario wins per",
591
+ " var). Unbound placeholders fail before any paid run. Inline delivery only.",
592
+ "",
593
+ "assertions:",
594
+ " target baseline must not fully pass; proposed must beat baseline pass rate",
595
+ " regression proposed must not fall below baseline pass rate",
596
+ " compare no assertion — reports both arms' pass rates and the delta",
597
+ " (for model-vs-model diffs with no directional claim)",
598
+ "",
599
+ "minimal scenario:",
600
+ " {",
601
+ " \"agent\": \"./agent.md\",",
602
+ " \"baselineSkills\": [\"./skill.baseline.md\"],",
603
+ " \"proposedSkills\": [\"./skill.proposed.md\"],",
604
+ " \"model\": \"sonnet\",",
605
+ " \"runs\": 5,",
606
+ " \"scenarios\": [{",
607
+ " \"name\": \"target-case\",",
608
+ " \"kind\": \"target\",",
609
+ " \"prompt\": \"Do the fixture task.\",",
610
+ " \"grader\": { \"type\": \"command\", \"command\": \"bun test\" }",
611
+ " }]",
612
+ " }",
613
+ ].join("\n");
614
+ }
615
+
616
+ function measureUsage(): string {
617
+ return [
618
+ "usage: promptdiff measure --scenario <scenario.json> [overrides]",
619
+ "",
620
+ "Characterizes ONE instruction set: runs every scenario N times, grades",
621
+ "deterministically, and reports per-case pass rates — no delta, no",
622
+ "assertions. Use it to know the current survival rate before changing",
623
+ "anything; faking this with identical compare arms produces nonsense",
624
+ "verdicts from sampling noise.",
625
+ "",
626
+ "The scenario file is the compare format; measure exercises the shared",
627
+ "`skills` set (or `baselineSkills`) and needs no proposed arm. All compare",
628
+ "config applies: render vars, images, pricing, productionModel, graders.",
629
+ "",
630
+ "overrides: --agent <file.md> --skill <SKILL.md|dir>... --model <model>",
631
+ " --runner <claude-p|openai> --base-url <url> --runs <n>",
632
+ " --mode <text|artifact> --tools <tools|default|''>",
633
+ " --sandbox <dir> --seed <dir> --keep-sandbox",
634
+ " --timeout-ms <ms> --max-budget-usd <usd> --receipts <dir>",
635
+ "",
636
+ "--receipts <dir> writes one <scenario>.receipt.json per scenario with",
637
+ "per-file prompt hashes and the measured rates (verdict \"measured\").",
638
+ "",
639
+ "Exit code is 0 whenever the runs complete — a measurement has no pass/fail.",
640
+ ].join("\n");
641
+ }
642
+
643
+ function calibrateUsage(): string {
644
+ return [
645
+ "usage: promptdiff calibrate --rubric <rubric.md> --model <judge-model> [flags]",
646
+ "",
647
+ "Measures a judge (rubric + model + runner) against labeled fixtures and",
648
+ "writes <rubric>.calibration.json next to the rubric. compare/measure refuse",
649
+ "to use a judge grader until this record exists, matches the current rubric",
650
+ "content (sha256) and judge model/runner, and clears minAccuracy on BOTH",
651
+ "classes — an uncalibrated judge is worse than the regex it replaces: same",
652
+ "wrongness, more confidence, higher cost.",
653
+ "",
654
+ "flags: --runner <claude-p|openai> --base-url <url>",
655
+ " --timeout-ms <ms> --max-budget-usd <usd>",
656
+ "",
657
+ "fixtures (sibling directory named after the rubric):",
658
+ " rubrics/negate-restate.md",
659
+ " rubrics/negate-restate.fixtures/pass/*.md outputs the judge must call clean",
660
+ " rubrics/negate-restate.fixtures/fail/*.md outputs the judge must flag",
661
+ "",
662
+ "Both classes need at least one fixture. Accuracy is reported per class on",
663
+ "purpose: a judge that passes everything scores 100% on the pass class and",
664
+ "0% on the fail class — overall accuracy would hide it.",
665
+ "",
666
+ "Exit code is 0 whenever the fixtures were judged — even a failing",
667
+ "calibration is recorded; the compare/measure gate is what enforces the bar.",
668
+ "Editing the rubric invalidates the record (content hash); recalibrate after",
669
+ "every rubric change.",
670
+ ].join("\n");
671
+ }
672
+
673
+ function generalUsage(): string {
674
+ return [
675
+ "usage: promptdiff <run|compare|measure|calibrate> [flags]",
676
+ "",
677
+ "commands:",
678
+ " run one bounded model invocation with inlined skills",
679
+ " compare N-run baseline-vs-proposed scenario comparison",
680
+ " measure N-run single-arm characterization (pass rates, no assertions)",
681
+ " calibrate measure a judge grader against labeled rubric fixtures",
682
+ "",
683
+ "use `promptdiff <command> --help` for command flags",
684
+ ].join("\n");
685
+ }