@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
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { mkdirSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { join, relative, resolve } from "node:path";
|
|
4
|
+
import type { CompareConfig } from "./config";
|
|
5
|
+
import type { CompareSummary, MeasureSummary } from "./compare";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A receipt asserts "the prompt with THIS content hash produced THIS eval
|
|
9
|
+
* result" — the content-addressed replacement for hand-maintained
|
|
10
|
+
* prompt_version strings. A consuming repo's CI can check that every prompt
|
|
11
|
+
* it ships has a passing receipt for its current hash; editing the prompt
|
|
12
|
+
* changes the hash and stales the receipt, naming the scenario to re-run.
|
|
13
|
+
*/
|
|
14
|
+
export interface PromptDigest {
|
|
15
|
+
path: string;
|
|
16
|
+
sha256: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface Receipt {
|
|
20
|
+
comparison: string;
|
|
21
|
+
scenario: string;
|
|
22
|
+
kind: string;
|
|
23
|
+
command: "compare" | "measure";
|
|
24
|
+
ranAt: string;
|
|
25
|
+
verdict: "pass" | "fail" | "none" | "measured";
|
|
26
|
+
prompts: {
|
|
27
|
+
agent: PromptDigest;
|
|
28
|
+
baselineSkills?: PromptDigest[];
|
|
29
|
+
proposedSkills?: PromptDigest[];
|
|
30
|
+
skills?: PromptDigest[];
|
|
31
|
+
};
|
|
32
|
+
/** Hash of the rendered system prompt(s) — also pins render-var fixtures. */
|
|
33
|
+
renderedPromptSha256?: unknown;
|
|
34
|
+
results: Record<string, { passes: number; totalRuns: number; passRate: number; costUsd: number }>;
|
|
35
|
+
samplingP?: number;
|
|
36
|
+
productionModel?: string;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function buildCompareReceipts(
|
|
40
|
+
summary: CompareSummary,
|
|
41
|
+
config: CompareConfig,
|
|
42
|
+
timestamp: string,
|
|
43
|
+
): Receipt[] {
|
|
44
|
+
const agent = digestPath(config.agent);
|
|
45
|
+
const baselineSkills = config.baselineSkills.map(digestPath);
|
|
46
|
+
const proposedSkills = config.proposedSkills.map(digestPath);
|
|
47
|
+
|
|
48
|
+
return summary.cases.map((caseSummary) => ({
|
|
49
|
+
comparison: summary.name,
|
|
50
|
+
scenario: caseSummary.name,
|
|
51
|
+
kind: caseSummary.kind,
|
|
52
|
+
command: "compare",
|
|
53
|
+
ranAt: timestamp,
|
|
54
|
+
// "compare" kind carries no directional claim, so its receipt cannot say pass.
|
|
55
|
+
verdict:
|
|
56
|
+
caseSummary.assertions.length > 0 ? "fail" : caseSummary.kind === "compare" ? "none" : "pass",
|
|
57
|
+
prompts: { agent, baselineSkills, proposedSkills },
|
|
58
|
+
renderedPromptSha256: caseSummary.promptSha256,
|
|
59
|
+
results: {
|
|
60
|
+
baseline: armResult(caseSummary.baseline),
|
|
61
|
+
proposed: armResult(caseSummary.proposed),
|
|
62
|
+
},
|
|
63
|
+
samplingP: caseSummary.samplingP,
|
|
64
|
+
productionModel: summary.productionModel,
|
|
65
|
+
}));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function buildMeasureReceipts(
|
|
69
|
+
summary: MeasureSummary,
|
|
70
|
+
config: CompareConfig,
|
|
71
|
+
timestamp: string,
|
|
72
|
+
): Receipt[] {
|
|
73
|
+
const agent = digestPath(config.agent);
|
|
74
|
+
const skills = config.baselineSkills.map(digestPath);
|
|
75
|
+
|
|
76
|
+
return summary.cases.map((caseSummary) => ({
|
|
77
|
+
comparison: summary.name,
|
|
78
|
+
scenario: caseSummary.name,
|
|
79
|
+
kind: caseSummary.kind,
|
|
80
|
+
command: "measure",
|
|
81
|
+
ranAt: timestamp,
|
|
82
|
+
verdict: "measured",
|
|
83
|
+
prompts: { agent, skills },
|
|
84
|
+
renderedPromptSha256: caseSummary.promptSha256,
|
|
85
|
+
results: { measured: armResult(caseSummary.result) },
|
|
86
|
+
productionModel: summary.productionModel,
|
|
87
|
+
}));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Writes one `<scenario-slug>.receipt.json` per receipt, overwriting — a
|
|
92
|
+
* receipt is current state; append-only history is `--report ndjson`'s job.
|
|
93
|
+
* Returns the written paths.
|
|
94
|
+
*/
|
|
95
|
+
export function writeReceipts(dir: string, receipts: Receipt[]): string[] {
|
|
96
|
+
const target = resolve(dir);
|
|
97
|
+
mkdirSync(target, { recursive: true });
|
|
98
|
+
return receipts.map((receipt) => {
|
|
99
|
+
const path = join(target, `${slug(receipt.scenario)}.receipt.json`);
|
|
100
|
+
writeFileSync(path, JSON.stringify(receipt, null, 2) + "\n", "utf8");
|
|
101
|
+
return path;
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function armResult(arm: { passes: number; totalRuns: number; passRate: number; totalCostUsd: number }) {
|
|
106
|
+
return { passes: arm.passes, totalRuns: arm.totalRuns, passRate: arm.passRate, costUsd: arm.totalCostUsd };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Repo-relative path + content hash; install-delivery skill dirs get a deterministic tree hash. */
|
|
110
|
+
function digestPath(absolutePath: string): PromptDigest {
|
|
111
|
+
return { path: relative(process.cwd(), absolutePath), sha256: hashPath(absolutePath) };
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** Content hash of a prompt file, or a deterministic tree hash for a skill directory. */
|
|
115
|
+
export function contentHash(path: string): string {
|
|
116
|
+
return hashPath(path);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function hashPath(path: string): string {
|
|
120
|
+
if (!statSync(path).isDirectory()) {
|
|
121
|
+
return createHash("sha256").update(readFileSync(path)).digest("hex");
|
|
122
|
+
}
|
|
123
|
+
// Directory (install-delivery skill): hash every file, sorted by relative
|
|
124
|
+
// path, so any supporting-file edit stales the receipt too.
|
|
125
|
+
const hash = createHash("sha256");
|
|
126
|
+
for (const entry of walkSorted(path, path)) {
|
|
127
|
+
hash.update(entry);
|
|
128
|
+
hash.update("\0");
|
|
129
|
+
hash.update(readFileSync(join(path, entry)));
|
|
130
|
+
hash.update("\0");
|
|
131
|
+
}
|
|
132
|
+
return hash.digest("hex");
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function walkSorted(root: string, dir: string): string[] {
|
|
136
|
+
const entries: string[] = [];
|
|
137
|
+
for (const name of readdirSync(dir).sort()) {
|
|
138
|
+
const full = join(dir, name);
|
|
139
|
+
if (statSync(full).isDirectory()) {
|
|
140
|
+
entries.push(...walkSorted(root, full));
|
|
141
|
+
} else {
|
|
142
|
+
entries.push(relative(root, full));
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return entries;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function slug(name: string): string {
|
|
149
|
+
return name.toLowerCase().replace(/[^a-z0-9_-]+/g, "-").replace(/^-+|-+$/g, "") || "scenario";
|
|
150
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
|
|
4
|
+
const PLACEHOLDER = /\{\{\s*([A-Za-z0-9_][A-Za-z0-9_.-]*)\s*\}\}/g;
|
|
5
|
+
|
|
6
|
+
export type RenderVars = Record<string, string>;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Resolves raw `render.vars` values: a value naming an existing file (relative
|
|
10
|
+
* to baseDir) becomes that file's contents; anything else stays a literal.
|
|
11
|
+
* File reads happen at load time so a missing fixture fails before any paid run.
|
|
12
|
+
*/
|
|
13
|
+
export function resolveRenderVars(
|
|
14
|
+
raw: Record<string, unknown>,
|
|
15
|
+
baseDir: string,
|
|
16
|
+
label: string,
|
|
17
|
+
): RenderVars {
|
|
18
|
+
// Null prototype so a var named "__proto__" binds as a normal own property
|
|
19
|
+
// instead of silently vanishing into a prototype assignment.
|
|
20
|
+
const vars: RenderVars = Object.create(null) as RenderVars;
|
|
21
|
+
for (const [name, value] of Object.entries(raw)) {
|
|
22
|
+
if (typeof value !== "string") {
|
|
23
|
+
throw new Error(`${label}.${name} must be a string (file path or literal)`);
|
|
24
|
+
}
|
|
25
|
+
const path = resolve(baseDir, value);
|
|
26
|
+
vars[name] = existsSync(path) && statSync(path).isFile() ? readFileSync(path, "utf8") : value;
|
|
27
|
+
}
|
|
28
|
+
return vars;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function placeholderNames(text: string): string[] {
|
|
32
|
+
return [...new Set([...text.matchAll(PLACEHOLDER)].map((match) => match[1]))];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Substitutes {{name}} placeholders and fails on any left unbound — sending a
|
|
37
|
+
* literal `{{draft}}` to a model produces a confusing pass/fail, not an error.
|
|
38
|
+
* Unbound detection runs on the ORIGINAL text: substituted values are never
|
|
39
|
+
* re-scanned, so a fixture that itself contains braces cannot false-positive
|
|
40
|
+
* (and there is no recursive expansion). Own-property checks throughout:
|
|
41
|
+
* `in`/bracket lookups would let an unbound {{toString}} or {{constructor}}
|
|
42
|
+
* pass the strict check and inject native-function source into a paid prompt.
|
|
43
|
+
*/
|
|
44
|
+
export function renderStrict(
|
|
45
|
+
text: string,
|
|
46
|
+
vars: RenderVars,
|
|
47
|
+
label: string,
|
|
48
|
+
hint = "bind them in render.vars",
|
|
49
|
+
): string {
|
|
50
|
+
const unbound = placeholderNames(text).filter((name) => !Object.hasOwn(vars, name));
|
|
51
|
+
if (unbound.length > 0) {
|
|
52
|
+
throw new Error(
|
|
53
|
+
`unbound template placeholder(s) in ${label}: ${unbound.map((name) => `{{${name}}}`).join(", ")} — ${hint}`,
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
return text.replace(PLACEHOLDER, (match, name: string) =>
|
|
57
|
+
Object.hasOwn(vars, name) ? vars[name] : match,
|
|
58
|
+
);
|
|
59
|
+
}
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { appendFileSync, mkdirSync } from "node:fs";
|
|
2
|
+
import { dirname, resolve } from "node:path";
|
|
3
|
+
import type { ArmConfig } from "./config";
|
|
4
|
+
import type { ArmSummary, CompareSummary } from "./compare";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* One NDJSON record per scenario per invocation, append-only: durable,
|
|
8
|
+
* greppable history that can answer "has the catch rate drifted" without
|
|
9
|
+
* hand-transcribing summaries into READMEs.
|
|
10
|
+
*/
|
|
11
|
+
export function buildReportRecords(summary: CompareSummary, timestamp: string): Array<Record<string, unknown>> {
|
|
12
|
+
return summary.cases.map((caseSummary) => ({
|
|
13
|
+
ts: timestamp,
|
|
14
|
+
comparison: summary.name,
|
|
15
|
+
scenario: caseSummary.name,
|
|
16
|
+
kind: caseSummary.kind,
|
|
17
|
+
baseline: armRecord(caseSummary.baseline, summary.arms.baseline),
|
|
18
|
+
proposed: armRecord(caseSummary.proposed, summary.arms.proposed),
|
|
19
|
+
deltaPassRate: caseSummary.proposed.passRate - caseSummary.baseline.passRate,
|
|
20
|
+
samplingP: caseSummary.samplingP,
|
|
21
|
+
failedAssertions: caseSummary.assertions,
|
|
22
|
+
passed: caseSummary.assertions.length === 0,
|
|
23
|
+
promptSha256: caseSummary.promptSha256,
|
|
24
|
+
productionModel: summary.productionModel,
|
|
25
|
+
}));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function appendNdjsonReport(
|
|
29
|
+
path: string,
|
|
30
|
+
summary: CompareSummary,
|
|
31
|
+
timestamp = new Date().toISOString(),
|
|
32
|
+
): number {
|
|
33
|
+
const records = buildReportRecords(summary, timestamp);
|
|
34
|
+
const target = resolve(path);
|
|
35
|
+
mkdirSync(dirname(target), { recursive: true });
|
|
36
|
+
appendFileSync(target, records.map((record) => JSON.stringify(record) + "\n").join(""), "utf8");
|
|
37
|
+
return records.length;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function armRecord(arm: ArmSummary, config: ArmConfig): Record<string, unknown> {
|
|
41
|
+
return {
|
|
42
|
+
model: config.model,
|
|
43
|
+
runner: config.runner,
|
|
44
|
+
passes: arm.passes,
|
|
45
|
+
totalRuns: arm.totalRuns,
|
|
46
|
+
passRate: arm.passRate,
|
|
47
|
+
costUsd: arm.totalCostUsd,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import {
|
|
2
|
+
cpSync,
|
|
3
|
+
existsSync,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
readdirSync,
|
|
6
|
+
realpathSync,
|
|
7
|
+
rmSync,
|
|
8
|
+
statSync,
|
|
9
|
+
} from "node:fs";
|
|
10
|
+
import { basename, join, resolve, sep } from "node:path";
|
|
11
|
+
|
|
12
|
+
export interface SandboxSpec {
|
|
13
|
+
root: string;
|
|
14
|
+
seed?: string;
|
|
15
|
+
prefix: string;
|
|
16
|
+
keep: boolean;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface PreparedSandbox {
|
|
20
|
+
dir: string;
|
|
21
|
+
cleanup(): void;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function prepareSandbox(spec: SandboxSpec): PreparedSandbox {
|
|
25
|
+
const root = resolve(spec.root);
|
|
26
|
+
mkdirSync(root, { recursive: true });
|
|
27
|
+
assertSeedIsUsable(spec.seed, root);
|
|
28
|
+
|
|
29
|
+
const dir = createUniqueDir(root, spec.prefix);
|
|
30
|
+
if (spec.seed) {
|
|
31
|
+
copyDirectoryContents(resolve(spec.seed), dir);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return {
|
|
35
|
+
dir,
|
|
36
|
+
cleanup() {
|
|
37
|
+
if (!spec.keep) {
|
|
38
|
+
rmSync(dir, { recursive: true, force: true });
|
|
39
|
+
}
|
|
40
|
+
},
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function createUniqueDir(root: string, prefix: string): string {
|
|
45
|
+
const safePrefix = prefix.replace(/[^a-zA-Z0-9._-]/g, "-").slice(0, 60) || "run";
|
|
46
|
+
let attempt = 0;
|
|
47
|
+
while (attempt < 1_000) {
|
|
48
|
+
const suffix = `${Date.now()}-${process.pid}-${attempt}`;
|
|
49
|
+
const dir = join(root, `${safePrefix}-${suffix}`);
|
|
50
|
+
try {
|
|
51
|
+
mkdirSync(dir);
|
|
52
|
+
return dir;
|
|
53
|
+
} catch (error) {
|
|
54
|
+
if (isAlreadyExists(error)) {
|
|
55
|
+
attempt += 1;
|
|
56
|
+
continue;
|
|
57
|
+
}
|
|
58
|
+
throw error;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
throw new Error(`could not create unique sandbox in ${root}`);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function assertSeedIsUsable(seed: string | undefined, root: string): void {
|
|
65
|
+
if (!seed) return;
|
|
66
|
+
const resolvedSeed = resolve(seed);
|
|
67
|
+
if (!existsSync(resolvedSeed)) {
|
|
68
|
+
throw new Error(`sandbox seed does not exist: ${seed}`);
|
|
69
|
+
}
|
|
70
|
+
if (!statSync(resolvedSeed).isDirectory()) {
|
|
71
|
+
throw new Error(`sandbox seed must be a directory: ${seed}`);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const realSeed = realpathSync(resolvedSeed);
|
|
75
|
+
const realRoot = realpathSync(root);
|
|
76
|
+
if (realRoot === realSeed || realRoot.startsWith(realSeed + sep)) {
|
|
77
|
+
throw new Error(`sandbox root must not be inside the seed directory: ${root}`);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function copyDirectoryContents(seed: string, destination: string): void {
|
|
82
|
+
for (const entry of readdirSync(seed)) {
|
|
83
|
+
cpSync(join(seed, entry), join(destination, basename(entry)), {
|
|
84
|
+
recursive: true,
|
|
85
|
+
errorOnExist: false,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function isAlreadyExists(error: unknown): boolean {
|
|
91
|
+
return (
|
|
92
|
+
typeof error === "object" &&
|
|
93
|
+
error !== null &&
|
|
94
|
+
"code" in error &&
|
|
95
|
+
(error as { code?: string }).code === "EEXIST"
|
|
96
|
+
);
|
|
97
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import { cpSync, existsSync, mkdirSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { basename, dirname, join, resolve } from "node:path";
|
|
4
|
+
|
|
5
|
+
export type Delivery = "inline" | "install";
|
|
6
|
+
|
|
7
|
+
export interface ResolvedSkill {
|
|
8
|
+
/** Directory that contains SKILL.md (and any supporting files). */
|
|
9
|
+
dir: string;
|
|
10
|
+
/** Registry name: frontmatter `name:` if present, else the directory basename. */
|
|
11
|
+
name: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface InstallResult {
|
|
15
|
+
installed: ResolvedSkill[];
|
|
16
|
+
warnings: string[];
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Accepts either a skill directory or a path to its SKILL.md and returns the
|
|
21
|
+
* directory plus the name Claude Code will register it under.
|
|
22
|
+
*/
|
|
23
|
+
export function resolveSkill(path: string): ResolvedSkill {
|
|
24
|
+
const resolved = resolve(path);
|
|
25
|
+
if (!existsSync(resolved)) {
|
|
26
|
+
throw new Error(`skill path does not exist: ${path}`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const dir = statSync(resolved).isDirectory() ? resolved : dirname(resolved);
|
|
30
|
+
const skillFile = join(dir, "SKILL.md");
|
|
31
|
+
if (!existsSync(skillFile)) {
|
|
32
|
+
throw new Error(`skill directory has no SKILL.md: ${dir}`);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return { dir, name: skillNameFrom(skillFile) ?? basename(dir) };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Copies skill directories into the sandbox's project skills path
|
|
40
|
+
* (<sandbox>/.claude/skills/<name>) with frontmatter intact, so headless Claude
|
|
41
|
+
* discovers them through the normal registry instead of inlined prompt text.
|
|
42
|
+
*/
|
|
43
|
+
export function installSkills(
|
|
44
|
+
skillPaths: string[],
|
|
45
|
+
sandboxDir: string,
|
|
46
|
+
userSkillsDir = defaultUserSkillsDir(),
|
|
47
|
+
): InstallResult {
|
|
48
|
+
const installed: ResolvedSkill[] = [];
|
|
49
|
+
const warnings: string[] = [];
|
|
50
|
+
const seen = new Set<string>();
|
|
51
|
+
|
|
52
|
+
for (const path of skillPaths) {
|
|
53
|
+
const skill = resolveSkill(path);
|
|
54
|
+
if (seen.has(skill.name)) {
|
|
55
|
+
throw new Error(`duplicate skill name in one arm: ${skill.name}`);
|
|
56
|
+
}
|
|
57
|
+
seen.add(skill.name);
|
|
58
|
+
|
|
59
|
+
const destination = join(sandboxDir, ".claude", "skills", skill.name);
|
|
60
|
+
mkdirSync(dirname(destination), { recursive: true });
|
|
61
|
+
cpSync(skill.dir, destination, { recursive: true });
|
|
62
|
+
installed.push(skill);
|
|
63
|
+
|
|
64
|
+
// A user-level skill with the same name loads in every run of every arm,
|
|
65
|
+
// contaminating the comparison. Detection only; unlinking is the operator's call.
|
|
66
|
+
if (existsSync(join(userSkillsDir, skill.name))) {
|
|
67
|
+
warnings.push(
|
|
68
|
+
`user-level skill "${skill.name}" exists at ${join(userSkillsDir, skill.name)} and will also load in both arms — remove or rename it for a clean comparison`,
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// Project skills with over-limit descriptions are silently dropped from the
|
|
73
|
+
// registry — the arm then runs skill-less and scores 0 for the wrong reason.
|
|
74
|
+
const descriptionLength = skillDescriptionLength(join(skill.dir, "SKILL.md"));
|
|
75
|
+
if (descriptionLength > MAX_DESCRIPTION_CHARS) {
|
|
76
|
+
warnings.push(
|
|
77
|
+
`skill "${skill.name}" has a ${descriptionLength}-char description (limit ${MAX_DESCRIPTION_CHARS}) — Claude Code drops over-limit project skills silently, so this arm would run without it`,
|
|
78
|
+
);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return { installed, warnings };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function deliveryValue(value: unknown, fallback: Delivery): Delivery {
|
|
86
|
+
if (value === undefined) return fallback;
|
|
87
|
+
if (value === "inline" || value === "install") return value;
|
|
88
|
+
throw new Error(`delivery must be "inline" or "install"`);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const MAX_DESCRIPTION_CHARS = 1024;
|
|
92
|
+
|
|
93
|
+
function skillDescriptionLength(skillFile: string): number {
|
|
94
|
+
const content = readFileSync(skillFile, "utf8");
|
|
95
|
+
const frontmatter = content.match(/^---[ \t]*(?:\r?\n)([\s\S]*?)(?:\r?\n)---/);
|
|
96
|
+
if (!frontmatter) return 0;
|
|
97
|
+
const description = frontmatter[1].match(/^description:\s*(.+)$/m);
|
|
98
|
+
return description ? description[1].length : 0;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function skillNameFrom(skillFile: string): string | undefined {
|
|
102
|
+
const content = readFileSync(skillFile, "utf8");
|
|
103
|
+
const frontmatter = content.match(/^---[ \t]*(?:\r?\n)([\s\S]*?)(?:\r?\n)---/);
|
|
104
|
+
if (!frontmatter) return undefined;
|
|
105
|
+
const name = frontmatter[1].match(/^name:\s*["']?([A-Za-z0-9][A-Za-z0-9_-]*)["']?\s*$/m);
|
|
106
|
+
return name?.[1];
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function defaultUserSkillsDir(): string {
|
|
110
|
+
const configDir = process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), ".claude");
|
|
111
|
+
return join(configDir, "skills");
|
|
112
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Two-tailed Fisher's exact test on a 2x2 pass/fail table. Exact enumeration
|
|
3
|
+
* is cheap at eval-sized n and avoids normal-approximation lies at n=3, which
|
|
4
|
+
* is exactly where the noise warning matters.
|
|
5
|
+
*/
|
|
6
|
+
export function fisherExactTwoTailedP(
|
|
7
|
+
baselinePasses: number,
|
|
8
|
+
baselineTotal: number,
|
|
9
|
+
proposedPasses: number,
|
|
10
|
+
proposedTotal: number,
|
|
11
|
+
): number {
|
|
12
|
+
const passColumn = baselinePasses + proposedPasses;
|
|
13
|
+
const n = baselineTotal + proposedTotal;
|
|
14
|
+
const kMin = Math.max(0, passColumn - proposedTotal);
|
|
15
|
+
const kMax = Math.min(baselineTotal, passColumn);
|
|
16
|
+
|
|
17
|
+
const logTableProb = (k: number) =>
|
|
18
|
+
logChoose(baselineTotal, k) + logChoose(proposedTotal, passColumn - k) - logChoose(n, passColumn);
|
|
19
|
+
|
|
20
|
+
const observed = logTableProb(baselinePasses);
|
|
21
|
+
let total = 0;
|
|
22
|
+
for (let k = kMin; k <= kMax; k += 1) {
|
|
23
|
+
const logP = logTableProb(k);
|
|
24
|
+
// Two-tailed by summing every table at most as probable as the observed one.
|
|
25
|
+
if (logP <= observed + 1e-9) total += Math.exp(logP);
|
|
26
|
+
}
|
|
27
|
+
return Math.min(1, total);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function logChoose(n: number, k: number): number {
|
|
31
|
+
return logFactorial(n) - logFactorial(k) - logFactorial(n - k);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const logFactorialCache: number[] = [0];
|
|
35
|
+
|
|
36
|
+
function logFactorial(n: number): number {
|
|
37
|
+
for (let i = logFactorialCache.length; i <= n; i += 1) {
|
|
38
|
+
logFactorialCache[i] = logFactorialCache[i - 1] + Math.log(i);
|
|
39
|
+
}
|
|
40
|
+
return logFactorialCache[n];
|
|
41
|
+
}
|
package/src/prompt.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
|
|
3
|
+
export function stripFrontmatter(markdown: string): string {
|
|
4
|
+
const match = markdown.match(/^---[ \t]*(?:\r?\n)[\s\S]*?(?:\r?\n)---[ \t]*(?:\r?\n|$)/);
|
|
5
|
+
if (!match) return markdown;
|
|
6
|
+
return markdown.slice(match[0].length);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function assembleSystemPrompt(agentPath: string, skillPaths: string[]): string {
|
|
10
|
+
const agentBody = stripFrontmatter(readFileSync(agentPath, "utf8"));
|
|
11
|
+
const skills = skillPaths.map((path) => {
|
|
12
|
+
const body = stripFrontmatter(readFileSync(path, "utf8"));
|
|
13
|
+
return `\n\n===== SKILL (inlined for eval): ${path} =====\n${body}`;
|
|
14
|
+
});
|
|
15
|
+
return agentBody + skills.join("");
|
|
16
|
+
}
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import type { RunResult, Runner, RunnerRunOptions } from "../types";
|
|
5
|
+
|
|
6
|
+
interface ClaudeJsonResult {
|
|
7
|
+
result?: unknown;
|
|
8
|
+
subtype?: unknown;
|
|
9
|
+
total_cost_usd?: unknown;
|
|
10
|
+
num_turns?: unknown;
|
|
11
|
+
duration_ms?: unknown;
|
|
12
|
+
modelUsage?: unknown;
|
|
13
|
+
errors?: unknown;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function buildClaudeArgs(options: RunnerRunOptions & { systemPromptFile: string }): string[] {
|
|
17
|
+
const systemPromptArgs =
|
|
18
|
+
options.systemPromptMode === "append"
|
|
19
|
+
? options.systemPrompt.trim().length > 0
|
|
20
|
+
? ["--append-system-prompt", options.systemPrompt]
|
|
21
|
+
: []
|
|
22
|
+
: ["--system-prompt-file", options.systemPromptFile];
|
|
23
|
+
|
|
24
|
+
// "--tools" controls availability only; an explicit tool list still hits
|
|
25
|
+
// permission denials in headless mode (Bash especially). Granting the same
|
|
26
|
+
// list via --allowedTools makes list-mode evals actually able to act.
|
|
27
|
+
const toolArgs =
|
|
28
|
+
options.tools === "" || options.tools === "default"
|
|
29
|
+
? ["--tools", options.tools]
|
|
30
|
+
: ["--tools", options.tools, "--allowedTools", options.tools];
|
|
31
|
+
|
|
32
|
+
const args = [
|
|
33
|
+
"-p",
|
|
34
|
+
options.userPrompt,
|
|
35
|
+
...systemPromptArgs,
|
|
36
|
+
"--output-format",
|
|
37
|
+
"json",
|
|
38
|
+
"--model",
|
|
39
|
+
options.model,
|
|
40
|
+
...toolArgs,
|
|
41
|
+
"--max-budget-usd",
|
|
42
|
+
String(options.maxBudgetUsd),
|
|
43
|
+
"--no-session-persistence",
|
|
44
|
+
// Headless denies file edits without an explicit permission mode, which breaks
|
|
45
|
+
// artifact-mode agents that must write outputs (e.g. findings.json) into the
|
|
46
|
+
// sandbox. acceptEdits is safe: text mode passes --tools "" so nothing can write,
|
|
47
|
+
// and artifact mode's cwd IS the disposable sandbox.
|
|
48
|
+
"--permission-mode",
|
|
49
|
+
"acceptEdits",
|
|
50
|
+
];
|
|
51
|
+
|
|
52
|
+
if (options.addDirs.length > 0) {
|
|
53
|
+
args.push("--add-dir", ...options.addDirs);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return args;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export class ClaudePrintRunner implements Runner {
|
|
60
|
+
readonly name = "claude-p";
|
|
61
|
+
readonly capabilities = { sandboxTools: true, skillRegistry: true, images: false };
|
|
62
|
+
|
|
63
|
+
constructor(private readonly claudeBin = "claude") {}
|
|
64
|
+
|
|
65
|
+
async run(options: RunnerRunOptions): Promise<RunResult> {
|
|
66
|
+
const promptDir = mkdtempSync(join(tmpdir(), "promptdiff-prompt-"));
|
|
67
|
+
const systemPromptFile = join(promptDir, "system-prompt.md");
|
|
68
|
+
writeFileSync(systemPromptFile, options.systemPrompt, "utf8");
|
|
69
|
+
|
|
70
|
+
try {
|
|
71
|
+
const args = buildClaudeArgs({ ...options, systemPromptFile });
|
|
72
|
+
const proc = Bun.spawn([this.claudeBin, ...args], {
|
|
73
|
+
cwd: options.cwd,
|
|
74
|
+
stdout: "pipe",
|
|
75
|
+
stderr: "pipe",
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
let timedOut = false;
|
|
79
|
+
const timeout = setTimeout(() => {
|
|
80
|
+
timedOut = true;
|
|
81
|
+
proc.kill("SIGTERM");
|
|
82
|
+
setTimeout(() => proc.kill("SIGKILL"), 2_000);
|
|
83
|
+
}, options.timeoutMs);
|
|
84
|
+
|
|
85
|
+
const [stdout, stderr, code] = await Promise.all([
|
|
86
|
+
new Response(proc.stdout).text(),
|
|
87
|
+
new Response(proc.stderr).text(),
|
|
88
|
+
proc.exited,
|
|
89
|
+
]);
|
|
90
|
+
clearTimeout(timeout);
|
|
91
|
+
|
|
92
|
+
if (timedOut) {
|
|
93
|
+
throw new Error(`claude timed out after ${options.timeoutMs}ms`);
|
|
94
|
+
}
|
|
95
|
+
if (code !== 0) {
|
|
96
|
+
throw new Error(describeClaudeFailure(code, stdout, stderr, options.maxBudgetUsd));
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
let parsed: ClaudeJsonResult;
|
|
100
|
+
try {
|
|
101
|
+
parsed = JSON.parse(stdout) as ClaudeJsonResult;
|
|
102
|
+
} catch (error) {
|
|
103
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
104
|
+
throw new Error(`claude returned invalid JSON: ${reason}\n${stdout.slice(0, 1_500)}`);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
return normalizeClaudeResult(parsed);
|
|
108
|
+
} finally {
|
|
109
|
+
rmSync(promptDir, { recursive: true, force: true });
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* A budget abort exits 1 with EMPTY stderr but a full JSON result on stdout
|
|
116
|
+
* ("subtype": "error_max_budget_usd") — without decoding it, the failure is
|
|
117
|
+
* indistinguishable from a crash and has cost real debug cycles.
|
|
118
|
+
*/
|
|
119
|
+
export function describeClaudeFailure(
|
|
120
|
+
code: number,
|
|
121
|
+
stdout: string,
|
|
122
|
+
stderr: string,
|
|
123
|
+
maxBudgetUsd: number,
|
|
124
|
+
): string {
|
|
125
|
+
let parsed: ClaudeJsonResult | undefined;
|
|
126
|
+
try {
|
|
127
|
+
parsed = JSON.parse(stdout) as ClaudeJsonResult;
|
|
128
|
+
} catch {
|
|
129
|
+
// Not JSON — fall through to the raw-stream message.
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (parsed && typeof parsed === "object") {
|
|
133
|
+
if (parsed.subtype === "error_max_budget_usd") {
|
|
134
|
+
const spent = typeof parsed.total_cost_usd === "number" ? ` after spending $${parsed.total_cost_usd.toFixed(4)}` : "";
|
|
135
|
+
return `claude hit the $${maxBudgetUsd} max budget${spent} — raise --max-budget-usd / maxBudgetUsd (the cap is checked between turns, so artifact-mode runs need headroom)`;
|
|
136
|
+
}
|
|
137
|
+
if (Array.isArray(parsed.errors) && parsed.errors.length > 0) {
|
|
138
|
+
return `claude exited ${code}: ${parsed.errors.map(String).join("; ").slice(0, 1_500)}`;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const detail = stderr.trim().length > 0 ? stderr : stdout;
|
|
143
|
+
return `claude exited ${code}: ${detail.slice(0, 1_500)}`;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function normalizeClaudeResult(result: ClaudeJsonResult): RunResult {
|
|
147
|
+
const modelUsage = result.modelUsage;
|
|
148
|
+
return {
|
|
149
|
+
output: typeof result.result === "string" ? result.result : "",
|
|
150
|
+
costUsd: typeof result.total_cost_usd === "number" ? result.total_cost_usd : 0,
|
|
151
|
+
turns: typeof result.num_turns === "number" ? result.num_turns : 0,
|
|
152
|
+
durationMs: typeof result.duration_ms === "number" ? result.duration_ms : 0,
|
|
153
|
+
models: modelUsage && typeof modelUsage === "object" ? Object.keys(modelUsage) : [],
|
|
154
|
+
raw: result,
|
|
155
|
+
};
|
|
156
|
+
}
|