harness-everything 0.5.3 → 0.6.0

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.
@@ -0,0 +1,781 @@
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ const fs = require('fs');
5
+ const os = require('os');
6
+ const path = require('path');
7
+ const crypto = require('crypto');
8
+ const { execFileSync, spawnSync } = require('child_process');
9
+ const {
10
+ buildWorkspace,
11
+ grade,
12
+ parseSimpleYaml,
13
+ } = require('./run');
14
+ const { verifyEvidence: verifyOpenCodeHardlockEvidence } = require('./opencode-hardlock-live');
15
+
16
+ const ROOT = path.resolve(__dirname, '..');
17
+ const CASES_DIR = path.join(__dirname, 'cases');
18
+ const RESULTS_ROOT = path.join(__dirname, 'results', 'paired');
19
+ const PLUGIN_SOURCE = path.join(ROOT, 'opencode-plugin', 'index.mjs');
20
+ const EFFECT_TYPES = new Set(['skill-text', 'plugin-enforcement']);
21
+ const DEFINITIVE = new Set(['pass', 'fail']);
22
+ const SNAPSHOT_EXCLUDES = new Set(['.git', '.claude', '.opencode', '.harness-src', 'node_modules']);
23
+
24
+ function sha256(value) {
25
+ return crypto.createHash('sha256').update(value).digest('hex');
26
+ }
27
+
28
+ function sha256File(file) {
29
+ return sha256(fs.readFileSync(file));
30
+ }
31
+
32
+ function stable(value) {
33
+ if (Array.isArray(value)) return value.map(stable);
34
+ if (!value || typeof value !== 'object') return value;
35
+ return Object.fromEntries(Object.keys(value).sort().map((key) => [key, stable(value[key])]));
36
+ }
37
+
38
+ function stableJson(value) {
39
+ return JSON.stringify(stable(value));
40
+ }
41
+
42
+ function discoverCases() {
43
+ return fs.readdirSync(CASES_DIR)
44
+ .filter((file) => file.endsWith('.yaml'))
45
+ .map((file) => ({
46
+ file: path.join(CASES_DIR, file),
47
+ ...parseSimpleYaml(fs.readFileSync(path.join(CASES_DIR, file), 'utf8')),
48
+ }));
49
+ }
50
+
51
+ function treatmentSkills(c) {
52
+ if (Array.isArray(c.loaded_skills)) return c.loaded_skills.slice();
53
+ if (c.discipline && fs.existsSync(path.join(ROOT, c.discipline, 'SKILL.md'))) return [c.discipline];
54
+ return [];
55
+ }
56
+
57
+ function safeCommand(command, args, options = {}) {
58
+ try {
59
+ return execFileSync(command, args, {
60
+ encoding: 'utf8',
61
+ stdio: ['ignore', 'pipe', 'pipe'],
62
+ ...options,
63
+ }).trim();
64
+ } catch {
65
+ return null;
66
+ }
67
+ }
68
+
69
+ function copySkill(ws, skill) {
70
+ const source = path.join(ROOT, skill);
71
+ if (!fs.existsSync(path.join(source, 'SKILL.md'))) throw new Error(`unknown skill: ${skill}`);
72
+ const destination = path.join(ws, '.claude', 'skills', skill);
73
+ fs.mkdirSync(path.dirname(destination), { recursive: true });
74
+ fs.cpSync(source, destination, { recursive: true });
75
+ }
76
+
77
+ function installSkillsOnly(ws, skills, engine) {
78
+ for (const skill of skills) copySkill(ws, skill);
79
+ if (engine === 'opencode') {
80
+ fs.writeFileSync(path.join(ws, 'opencode.json'), JSON.stringify({
81
+ $schema: 'https://opencode.ai/config.json',
82
+ instructions: skills.map((skill) => `.claude/skills/${skill}/SKILL.md`),
83
+ }, null, 2));
84
+ }
85
+ }
86
+
87
+ function installOpenCodePlugin(ws) {
88
+ const pluginDir = path.join(ws, '.opencode', 'plugins');
89
+ fs.mkdirSync(pluginDir, { recursive: true });
90
+ const installed = path.join(pluginDir, 'harness-enforcement.js');
91
+ fs.copyFileSync(PLUGIN_SOURCE, installed);
92
+ const sourceHash = sha256File(PLUGIN_SOURCE);
93
+ const installedHash = sha256File(installed);
94
+ if (sourceHash !== installedHash) throw new Error('installed OpenCode plugin does not match canonical source');
95
+ return { installed, sha256: sourceHash };
96
+ }
97
+
98
+ function prepareArmWorkspace(c, effectType, arm, engine) {
99
+ const ws = buildWorkspace(c);
100
+ const namedSkills = treatmentSkills(c);
101
+ const treatment = arm === 'treatment';
102
+ let skills = [];
103
+ let plugin = null;
104
+
105
+ if (effectType === 'skill-text') {
106
+ skills = treatment ? namedSkills : [];
107
+ installSkillsOnly(ws, skills, engine);
108
+ if (engine === 'opencode' && !fs.existsSync(path.join(ws, 'opencode.json'))) {
109
+ installSkillsOnly(ws, [], engine);
110
+ }
111
+ } else if (effectType === 'plugin-enforcement') {
112
+ skills = namedSkills;
113
+ installSkillsOnly(ws, skills, engine);
114
+ fs.mkdirSync(path.join(ws, '.opencode', 'plugins'), { recursive: true });
115
+ if (treatment) plugin = installOpenCodePlugin(ws);
116
+ } else {
117
+ throw new Error(`unsupported effect type: ${effectType}`);
118
+ }
119
+
120
+ return {
121
+ ws,
122
+ namedSkills,
123
+ loadedSkills: skills,
124
+ plugin,
125
+ intervention: {
126
+ skill_text: effectType === 'skill-text' ? treatment : true,
127
+ plugin_enforcement: effectType === 'plugin-enforcement' ? treatment : false,
128
+ },
129
+ };
130
+ }
131
+
132
+ function expandWindowsBatchPath(value, batchPath) {
133
+ const base = path.dirname(batchPath) + path.sep;
134
+ return value
135
+ .replace(/%~dp0/gi, base)
136
+ .replace(/%dp0%/gi, base)
137
+ .replace(/^"|"$/g, '')
138
+ .trim();
139
+ }
140
+
141
+ function windowsCommandCandidates(command) {
142
+ try {
143
+ return execFileSync('where.exe', [command], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] })
144
+ .split(/\r?\n/)
145
+ .map((line) => line.trim())
146
+ .filter((line) => line && !/^INFO:/i.test(line));
147
+ } catch {
148
+ return [];
149
+ }
150
+ }
151
+
152
+ function resolveCliInvocation(invocation) {
153
+ if (process.platform !== 'win32') return invocation;
154
+ const candidates = windowsCommandCandidates(invocation.command);
155
+ const native = candidates.find((candidate) => /\.exe$/i.test(candidate));
156
+ if (native) return { command: native, args: invocation.args.slice() };
157
+ const shim = candidates.find((candidate) => /\.(?:cmd|bat)$/i.test(candidate));
158
+ if (shim) {
159
+ let source = '';
160
+ try { source = fs.readFileSync(shim, 'utf8'); } catch { source = ''; }
161
+ const nodeEntry = source.match(/(?:^|\s)(?:node(?:\.exe)?)(?:\s+)(?:"([^"]+\.js)"|([^\s]+\.js))/im);
162
+ if (nodeEntry) {
163
+ const entry = expandWindowsBatchPath(nodeEntry[1] || nodeEntry[2], shim);
164
+ return { command: process.execPath, args: [entry, ...invocation.args] };
165
+ }
166
+ const binaryEntry = source.match(/"?([^"\r\n]+\.exe)"?\s+%\*/i);
167
+ if (binaryEntry) return { command: expandWindowsBatchPath(binaryEntry[1], shim), args: invocation.args.slice() };
168
+ }
169
+ const direct = candidates.find((candidate) => !/\.(?:cmd|bat|ps1)$/i.test(candidate));
170
+ return direct ? { command: direct, args: invocation.args.slice() } : invocation;
171
+ }
172
+
173
+ function buildInvocation(engine, model, prompt, ws, maxTurns) {
174
+ if (engine === 'opencode') {
175
+ return resolveCliInvocation({
176
+ command: 'opencode',
177
+ args: ['run', '--format', 'json', '--auto', '--dir', ws, '-m', model, prompt],
178
+ });
179
+ }
180
+ if (engine === 'claude') {
181
+ return resolveCliInvocation({
182
+ command: 'claude',
183
+ args: [
184
+ '-p', prompt,
185
+ '--output-format', 'stream-json',
186
+ '--verbose',
187
+ '--max-turns', String(maxTurns),
188
+ '--dangerously-skip-permissions',
189
+ '--setting-sources', 'project,local',
190
+ '--model', model,
191
+ ],
192
+ });
193
+ }
194
+ throw new Error(`unsupported engine: ${engine}`);
195
+ }
196
+
197
+ function copyWorkspaceSnapshot(ws, destination) {
198
+ fs.mkdirSync(destination, { recursive: true });
199
+ fs.cpSync(ws, destination, {
200
+ recursive: true,
201
+ filter: (source) => {
202
+ if (source === ws) return true;
203
+ const relative = path.relative(ws, source);
204
+ const first = relative.split(path.sep)[0];
205
+ return !SNAPSHOT_EXCLUDES.has(first);
206
+ },
207
+ });
208
+ }
209
+
210
+ function recursiveFind(root, names) {
211
+ if (!fs.existsSync(root)) return [];
212
+ const out = [];
213
+ for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
214
+ const file = path.join(root, entry.name);
215
+ if (entry.isDirectory()) out.push(...recursiveFind(file, names));
216
+ else if (entry.isFile() && names.has(entry.name)) out.push(file);
217
+ }
218
+ return out;
219
+ }
220
+
221
+ function inspectPluginAttribution(stateHome) {
222
+ const stateFiles = recursiveFind(stateHome, new Set(['edit-state.json', 'circuit-breaker.json', 'compliance.json']));
223
+ return {
224
+ fired: stateFiles.length > 0,
225
+ state_files: stateFiles.map((file) => path.relative(stateHome, file).split(path.sep).join('/')).sort(),
226
+ };
227
+ }
228
+
229
+ function usageTotal(usage) {
230
+ if (!usage || typeof usage !== 'object') return null;
231
+ for (const key of ['total_tokens', 'totalTokens', 'tokens']) {
232
+ if (typeof usage[key] === 'number') return usage[key];
233
+ }
234
+ const input = ['input_tokens', 'inputTokens', 'prompt_tokens', 'promptTokens']
235
+ .map((key) => usage[key]).find((value) => typeof value === 'number');
236
+ const output = ['output_tokens', 'outputTokens', 'completion_tokens', 'completionTokens']
237
+ .map((key) => usage[key]).find((value) => typeof value === 'number');
238
+ return typeof input === 'number' && typeof output === 'number' ? input + output : null;
239
+ }
240
+
241
+ function runArm(c, context, arm, pairDir) {
242
+ const prepared = prepareArmWorkspace(c, context.effect_type, arm, context.engine);
243
+ const stateHome = fs.mkdtempSync(path.join(os.tmpdir(), `harness-paired-state-${arm}-`));
244
+ const transcriptFile = path.join(pairDir, `${arm}.transcript.jsonl`);
245
+ const stderrFile = path.join(pairDir, `${arm}.stderr.txt`);
246
+ const snapshotDir = path.join(pairDir, `${arm}.workspace`);
247
+ const stateArchiveDir = path.join(pairDir, `${arm}.state`);
248
+ const invocation = buildInvocation(context.engine, context.model, c.prompt, prepared.ws, c.max_turns);
249
+ const started = Date.now();
250
+ const result = spawnSync(invocation.command, invocation.args, {
251
+ cwd: prepared.ws,
252
+ env: { ...process.env, HARNESS_STATE_HOME: stateHome },
253
+ encoding: 'utf8',
254
+ timeout: context.engine === 'opencode' ? 20 * 60 * 1000 : 15 * 60 * 1000,
255
+ maxBuffer: 64 * 1024 * 1024,
256
+ windowsHide: true,
257
+ });
258
+ const durationMs = Date.now() - started;
259
+ fs.writeFileSync(transcriptFile, result.stdout || '', 'utf8');
260
+ fs.writeFileSync(stderrFile, result.stderr || '', 'utf8');
261
+ copyWorkspaceSnapshot(prepared.ws, snapshotDir);
262
+ if (fs.existsSync(stateHome)) fs.cpSync(stateHome, stateArchiveDir, { recursive: true });
263
+
264
+ const base = {
265
+ arm,
266
+ intervention: prepared.intervention,
267
+ loaded_skills: prepared.loadedSkills,
268
+ plugin_sha256: prepared.plugin ? prepared.plugin.sha256 : null,
269
+ transcript: path.basename(transcriptFile),
270
+ stderr: path.basename(stderrFile),
271
+ workspace_snapshot: path.basename(snapshotDir),
272
+ state_snapshot: path.basename(stateArchiveDir),
273
+ cli_exit_status: result.status,
274
+ cli_signal: result.signal || null,
275
+ duration_ms_wall: durationMs,
276
+ };
277
+
278
+ if (result.error || result.status !== 0) {
279
+ fs.rmSync(prepared.ws, { recursive: true, force: true });
280
+ fs.rmSync(stateHome, { recursive: true, force: true });
281
+ return {
282
+ ...base,
283
+ outcome: 'session-error',
284
+ infrastructure_reason: result.error
285
+ ? String(result.error.message || result.error).slice(0, 500)
286
+ : `CLI exited with status ${result.status}`,
287
+ model_name: null,
288
+ cost: null,
289
+ usage: null,
290
+ total_tokens: null,
291
+ tool_call_count: null,
292
+ expectations: [],
293
+ plugin_attribution: context.effect_type === 'plugin-enforcement' && arm === 'treatment'
294
+ ? inspectPluginAttribution(stateArchiveDir)
295
+ : null,
296
+ };
297
+ }
298
+
299
+ let graded;
300
+ try {
301
+ graded = grade(c, prepared.ws, transcriptFile, context.engine);
302
+ } catch (error) {
303
+ fs.rmSync(prepared.ws, { recursive: true, force: true });
304
+ fs.rmSync(stateHome, { recursive: true, force: true });
305
+ return {
306
+ ...base,
307
+ outcome: 'grader-error',
308
+ infrastructure_reason: String(error.message || error).slice(0, 500),
309
+ model_name: null,
310
+ cost: null,
311
+ usage: null,
312
+ total_tokens: null,
313
+ tool_call_count: null,
314
+ expectations: [],
315
+ plugin_attribution: null,
316
+ };
317
+ }
318
+
319
+ const metadata = graded.parsed.metadata || {};
320
+ const pluginAttribution = context.effect_type === 'plugin-enforcement' && arm === 'treatment'
321
+ ? inspectPluginAttribution(stateArchiveDir)
322
+ : null;
323
+ let outcome = graded.status;
324
+ let infrastructureReason = null;
325
+ if (pluginAttribution && !pluginAttribution.fired) {
326
+ outcome = 'inconclusive';
327
+ infrastructureReason = 'plugin treatment produced no retained OpenCode enforcement state; attribution missing';
328
+ }
329
+
330
+ const toolCount = graded.executionEvidence && graded.executionEvidence.counts
331
+ ? graded.executionEvidence.counts.attempted
332
+ : null;
333
+ const armRecord = {
334
+ ...base,
335
+ outcome,
336
+ infrastructure_reason: infrastructureReason,
337
+ parse_status: graded.parsed.parseStatus,
338
+ parse_error: graded.parsed.parseError,
339
+ transcript_format: graded.parsed.format,
340
+ model_name: metadata.model || null,
341
+ cost: typeof metadata.cost === 'number' ? metadata.cost : null,
342
+ usage: metadata.usage || null,
343
+ total_tokens: usageTotal(metadata.usage),
344
+ duration_ms: typeof metadata.duration_ms === 'number' ? metadata.duration_ms : durationMs,
345
+ num_turns: typeof metadata.num_turns === 'number' ? metadata.num_turns : null,
346
+ tool_call_count: toolCount,
347
+ expectations: graded.results.map(({ type, value, command, after_edit, informational, description, pass, status, reason }) => ({
348
+ type, value, command, after_edit, informational: !!informational, description, pass, status, reason,
349
+ evidence_class: type === 'command_exit_0' ? 'post-hoc-workspace-check' :
350
+ String(type || '').startsWith('tool_') || type === 'execution_evidence' ? 'observed-tool-event' : 'behavior-or-workspace',
351
+ })),
352
+ plugin_attribution: pluginAttribution,
353
+ };
354
+
355
+ fs.rmSync(prepared.ws, { recursive: true, force: true });
356
+ fs.rmSync(stateHome, { recursive: true, force: true });
357
+ return armRecord;
358
+ }
359
+
360
+ function validatePreflight(preflightDir) {
361
+ if (!preflightDir) return { passed: false, reason: 'plugin-enforcement requires --opencode-preflight <evidence-dir>' };
362
+ const absolute = path.resolve(preflightDir);
363
+ let verification;
364
+ try { verification = verifyOpenCodeHardlockEvidence(absolute); }
365
+ catch (error) { return { passed: false, reason: `preflight unreadable: ${error.message}` }; }
366
+ if (!verification.passed) return { passed: false, reason: 'OpenCode hard-lock preflight evidence does not pass verification' };
367
+ let metadata;
368
+ try { metadata = JSON.parse(fs.readFileSync(path.join(absolute, 'metadata.json'), 'utf8')); }
369
+ catch (error) { return { passed: false, reason: `preflight metadata unreadable: ${error.message}` }; }
370
+ const currentHash = sha256File(PLUGIN_SOURCE);
371
+ if (!metadata.pluginSha256 || metadata.pluginSha256 !== currentHash) {
372
+ return { passed: false, reason: 'preflight plugin hash does not match the current canonical OpenCode plugin' };
373
+ }
374
+ return {
375
+ passed: true,
376
+ evidence_dir: absolute,
377
+ plugin_sha256: currentHash,
378
+ repo_commit: metadata.repoCommit || null,
379
+ opencode_version: metadata.opencodeVersion || null,
380
+ };
381
+ }
382
+
383
+ function validateRunConfig(options) {
384
+ const problems = [];
385
+ if (!EFFECT_TYPES.has(options.effect_type)) problems.push('effect must be skill-text or plugin-enforcement');
386
+ if (!['claude', 'opencode'].includes(options.engine)) problems.push('engine must be claude or opencode');
387
+ if (!options.model || !String(options.model).trim()) problems.push('an explicit --model is required for paired comparability');
388
+ if (!Number.isFinite(options.min_effect_pp) || options.min_effect_pp < 0 || options.min_effect_pp > 100) {
389
+ problems.push('--min-effect-pp must be explicitly set between 0 and 100 before the run');
390
+ }
391
+ if (!Number.isInteger(options.repeats) || options.repeats < 1) problems.push('--repeats must be an integer >= 1');
392
+ if (options.effect_type === 'plugin-enforcement' && options.engine !== 'opencode') {
393
+ problems.push('plugin-enforcement currently supports engine=opencode only');
394
+ }
395
+ return problems;
396
+ }
397
+
398
+ function pairContract(c, context) {
399
+ return {
400
+ schema_version: 1,
401
+ effect_type: context.effect_type,
402
+ engine: context.engine,
403
+ requested_model: context.model,
404
+ case_id: c.id,
405
+ max_turns: c.max_turns,
406
+ loaded_skills: treatmentSkills(c).slice().sort(),
407
+ fixture_sha256: sha256(stableJson(c.fixture)),
408
+ prompt_sha256: sha256(c.prompt),
409
+ rubric_sha256: sha256(stableJson(c.expectations)),
410
+ execution_policy: context.engine === 'claude'
411
+ ? 'dangerously-skip-permissions/project-local-sources'
412
+ : 'opencode-auto',
413
+ };
414
+ }
415
+
416
+ function validatePairRecord(record) {
417
+ const baseline = record && record.arms && record.arms.baseline;
418
+ const treatment = record && record.arms && record.arms.treatment;
419
+ if (!baseline || !treatment) return { included: false, reason: 'missing-arm' };
420
+ if (!DEFINITIVE.has(baseline.outcome) || !DEFINITIVE.has(treatment.outcome)) {
421
+ const outcomes = [baseline.outcome, treatment.outcome].join(' / ');
422
+ return { included: false, reason: `non-definitive:${outcomes}` };
423
+ }
424
+ if (!record.contract || sha256(stableJson(record.contract)) !== record.contract_sha256) {
425
+ return { included: false, reason: 'pair-contract-hash-mismatch' };
426
+ }
427
+ if (baseline.model_name && treatment.model_name && baseline.model_name !== treatment.model_name) {
428
+ return { included: false, reason: 'actual-model-mismatch' };
429
+ }
430
+ if (record.effect_type === 'skill-text') {
431
+ if (baseline.intervention.skill_text || treatment.intervention.skill_text !== true ||
432
+ baseline.intervention.plugin_enforcement || treatment.intervention.plugin_enforcement) {
433
+ return { included: false, reason: 'skill-text-intervention-shape-mismatch' };
434
+ }
435
+ if (baseline.loaded_skills.length !== 0 || stableJson(treatment.loaded_skills.slice().sort()) !== stableJson(record.contract.loaded_skills)) {
436
+ return { included: false, reason: 'skill-text-loaded-skills-mismatch' };
437
+ }
438
+ } else if (record.effect_type === 'plugin-enforcement') {
439
+ if (!baseline.intervention.skill_text || !treatment.intervention.skill_text ||
440
+ baseline.intervention.plugin_enforcement || treatment.intervention.plugin_enforcement !== true) {
441
+ return { included: false, reason: 'plugin-intervention-shape-mismatch' };
442
+ }
443
+ if (stableJson(baseline.loaded_skills.slice().sort()) !== stableJson(treatment.loaded_skills.slice().sort()) ||
444
+ stableJson(treatment.loaded_skills.slice().sort()) !== stableJson(record.contract.loaded_skills)) {
445
+ return { included: false, reason: 'plugin-skill-text-not-held-constant' };
446
+ }
447
+ if (!record.preflight || record.preflight.passed !== true || !treatment.plugin_attribution || treatment.plugin_attribution.fired !== true) {
448
+ return { included: false, reason: 'plugin-attribution-missing' };
449
+ }
450
+ if (!treatment.plugin_sha256 || treatment.plugin_sha256 !== record.preflight.plugin_sha256) {
451
+ return { included: false, reason: 'plugin-hash-mismatch' };
452
+ }
453
+ } else {
454
+ return { included: false, reason: 'unknown-effect-type' };
455
+ }
456
+ return { included: true, reason: null };
457
+ }
458
+
459
+ function transitionOf(record) {
460
+ const baseline = record.arms.baseline.outcome;
461
+ const treatment = record.arms.treatment.outcome;
462
+ return `${baseline}->${treatment}`;
463
+ }
464
+
465
+ function sampleVariance(values) {
466
+ if (values.length < 2) return null;
467
+ const mean = values.reduce((sum, value) => sum + value, 0) / values.length;
468
+ return values.reduce((sum, value) => sum + ((value - mean) ** 2), 0) / (values.length - 1);
469
+ }
470
+
471
+ function seedFrom(text) {
472
+ return parseInt(sha256(text).slice(0, 8), 16) || 1;
473
+ }
474
+
475
+ function xorshift32(seed) {
476
+ let state = seed >>> 0;
477
+ return () => {
478
+ state ^= state << 13;
479
+ state ^= state >>> 17;
480
+ state ^= state << 5;
481
+ return (state >>> 0) / 0x100000000;
482
+ };
483
+ }
484
+
485
+ function percentile(sorted, q) {
486
+ if (!sorted.length) return null;
487
+ const position = (sorted.length - 1) * q;
488
+ const lower = Math.floor(position);
489
+ const upper = Math.ceil(position);
490
+ if (lower === upper) return sorted[lower];
491
+ const weight = position - lower;
492
+ return sorted[lower] * (1 - weight) + sorted[upper] * weight;
493
+ }
494
+
495
+ function bootstrapPairedEffect(values, seedText, samples = 5000) {
496
+ if (!values.length) return null;
497
+ const random = xorshift32(seedFrom(seedText));
498
+ const means = [];
499
+ for (let sample = 0; sample < samples; sample++) {
500
+ let sum = 0;
501
+ for (let i = 0; i < values.length; i++) sum += values[Math.floor(random() * values.length)];
502
+ means.push((sum / values.length) * 100);
503
+ }
504
+ means.sort((a, b) => a - b);
505
+ return [percentile(means, 0.025), percentile(means, 0.975)].map((value) => Number(value.toFixed(2)));
506
+ }
507
+
508
+ function binomialCoefficient(n, k) {
509
+ if (k < 0 || k > n) return 0;
510
+ k = Math.min(k, n - k);
511
+ let result = 1;
512
+ for (let i = 1; i <= k; i++) result = result * (n - k + i) / i;
513
+ return result;
514
+ }
515
+
516
+ function mcnemarExactP(failToPass, passToFail) {
517
+ const n = failToPass + passToFail;
518
+ if (!n) return 1;
519
+ const k = Math.min(failToPass, passToFail);
520
+ let tail = 0;
521
+ for (let i = 0; i <= k; i++) tail += binomialCoefficient(n, i) * (0.5 ** n);
522
+ return Number(Math.min(1, 2 * tail).toFixed(6));
523
+ }
524
+
525
+ function meanDelta(records, field) {
526
+ const values = [];
527
+ for (const record of records) {
528
+ const baseline = record.arms.baseline[field];
529
+ const treatment = record.arms.treatment[field];
530
+ if (typeof baseline === 'number' && typeof treatment === 'number') values.push(treatment - baseline);
531
+ }
532
+ if (!values.length) return { n: 0, mean: null };
533
+ return { n: values.length, mean: Number((values.reduce((a, b) => a + b, 0) / values.length).toFixed(4)) };
534
+ }
535
+
536
+ function summarizePairs(records, minEffectPp) {
537
+ const included = [];
538
+ const excluded = [];
539
+ for (const record of records) {
540
+ const validation = validatePairRecord(record);
541
+ if (validation.included) included.push(record);
542
+ else excluded.push({ pair_id: record.pair_id || null, case_id: record.id || null, reason: validation.reason });
543
+ }
544
+
545
+ const transitions = {
546
+ 'fail->pass': 0,
547
+ 'pass->fail': 0,
548
+ 'pass->pass': 0,
549
+ 'fail->fail': 0,
550
+ };
551
+ const differences = [];
552
+ for (const record of included) {
553
+ const transition = transitionOf(record);
554
+ transitions[transition]++;
555
+ const baseline = record.arms.baseline.outcome === 'pass' ? 1 : 0;
556
+ const treatment = record.arms.treatment.outcome === 'pass' ? 1 : 0;
557
+ differences.push(treatment - baseline);
558
+ }
559
+
560
+ const baselinePass = included.filter((record) => record.arms.baseline.outcome === 'pass').length;
561
+ const treatmentPass = included.filter((record) => record.arms.treatment.outcome === 'pass').length;
562
+ const effectPp = included.length ? ((treatmentPass - baselinePass) / included.length) * 100 : null;
563
+ const ci = bootstrapPairedEffect(differences, included.map((record) => record.pair_id).sort().join('|'));
564
+
565
+ const byCase = {};
566
+ for (const record of included) {
567
+ if (!byCase[record.id]) byCase[record.id] = [];
568
+ const baseline = record.arms.baseline.outcome === 'pass' ? 1 : 0;
569
+ const treatment = record.arms.treatment.outcome === 'pass' ? 1 : 0;
570
+ byCase[record.id].push(treatment - baseline);
571
+ }
572
+ const repeatVariance = Object.fromEntries(Object.entries(byCase).map(([id, values]) => [id, {
573
+ repeats: values.length,
574
+ paired_difference_mean: Number((values.reduce((a, b) => a + b, 0) / values.length).toFixed(4)),
575
+ paired_difference_variance: sampleVariance(values) === null ? null : Number(sampleVariance(values).toFixed(4)),
576
+ }]));
577
+
578
+ let decision = 'insufficient-data';
579
+ if (included.length >= 2 && ci) {
580
+ if (ci[0] >= minEffectPp) decision = 'meets-predeclared-threshold';
581
+ else if (ci[1] < minEffectPp) decision = 'below-predeclared-threshold';
582
+ else decision = 'uncertain-at-predeclared-threshold';
583
+ }
584
+
585
+ return {
586
+ schema_version: 1,
587
+ requested_pairs: records.length,
588
+ included_pairs: included.length,
589
+ excluded_pairs: excluded.length,
590
+ exclusions: excluded,
591
+ arm_outcomes: {
592
+ baseline: { pass: baselinePass, fail: included.length - baselinePass },
593
+ treatment: { pass: treatmentPass, fail: included.length - treatmentPass },
594
+ },
595
+ transitions,
596
+ paired_effect_pp: effectPp === null ? null : Number(effectPp.toFixed(2)),
597
+ paired_effect_ci95_pp: ci,
598
+ mcnemar_exact_p: mcnemarExactP(transitions['fail->pass'], transitions['pass->fail']),
599
+ decision: {
600
+ min_effect_pp: minEffectPp,
601
+ status: decision,
602
+ rule: 'claim threshold met only when the paired bootstrap 95% CI lower bound is at or above the predeclared minimum effect',
603
+ },
604
+ repeat_variance: repeatVariance,
605
+ cost_and_execution_overhead: {
606
+ cost_delta: meanDelta(included, 'cost'),
607
+ token_delta: meanDelta(included, 'total_tokens'),
608
+ tool_call_delta: meanDelta(included, 'tool_call_count'),
609
+ duration_ms_delta: meanDelta(included, 'duration_ms'),
610
+ },
611
+ };
612
+ }
613
+
614
+ function flag(args, name) {
615
+ const index = args.indexOf(name);
616
+ return index >= 0 ? args[index + 1] : undefined;
617
+ }
618
+
619
+ function integerFlag(args, name, fallback) {
620
+ const raw = flag(args, name);
621
+ return raw === undefined ? fallback : Number(raw);
622
+ }
623
+
624
+ function loadPairFiles(experimentDir) {
625
+ const pairsDir = path.join(experimentDir, 'pairs');
626
+ if (!fs.existsSync(pairsDir)) return [];
627
+ return fs.readdirSync(pairsDir)
628
+ .filter((name) => name.endsWith('.json'))
629
+ .map((name) => JSON.parse(fs.readFileSync(path.join(pairsDir, name), 'utf8')));
630
+ }
631
+
632
+ function runExperiment(options) {
633
+ const problems = validateRunConfig(options);
634
+ if (problems.length) throw new Error(problems.join('; '));
635
+ const cases = discoverCases().filter((c) => !options.case_id || c.id === options.case_id);
636
+ if (!cases.length) throw new Error(`no matching case: ${options.case_id || '(none)'}`);
637
+
638
+ let preflight = null;
639
+ if (options.effect_type === 'plugin-enforcement') {
640
+ preflight = validatePreflight(options.preflight);
641
+ if (!preflight.passed) throw new Error(preflight.reason);
642
+ }
643
+
644
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
645
+ const experimentId = `${options.effect_type}-${timestamp}-${crypto.randomBytes(4).toString('hex')}`;
646
+ const experimentDir = path.join(RESULTS_ROOT, experimentId);
647
+ const pairsDir = path.join(experimentDir, 'pairs');
648
+ fs.mkdirSync(pairsDir, { recursive: true });
649
+
650
+ const context = {
651
+ schema_version: 1,
652
+ experiment_id: experimentId,
653
+ effect_type: options.effect_type,
654
+ engine: options.engine,
655
+ model: options.model,
656
+ min_effect_pp: options.min_effect_pp,
657
+ repeats: options.repeats,
658
+ preflight,
659
+ repo_commit: safeCommand('git', ['rev-parse', 'HEAD'], { cwd: ROOT }),
660
+ cli_version: safeCommand(options.engine === 'claude' ? 'claude' : 'opencode', ['--version']),
661
+ node_version: process.version,
662
+ platform: process.platform,
663
+ arch: process.arch,
664
+ created_at: new Date().toISOString(),
665
+ };
666
+ fs.writeFileSync(path.join(experimentDir, 'manifest.json'), JSON.stringify(context, null, 2));
667
+
668
+ const records = [];
669
+ for (let repeat = 1; repeat <= options.repeats; repeat++) {
670
+ for (const c of cases) {
671
+ const pairId = `${c.id}-r${repeat}-${crypto.randomUUID()}`;
672
+ const pairDir = path.join(experimentDir, pairId);
673
+ fs.mkdirSync(pairDir, { recursive: true });
674
+ const contract = pairContract(c, context);
675
+ const contractHash = sha256(stableJson(contract));
676
+ const order = crypto.randomInt(0, 2) === 0 ? ['baseline', 'treatment'] : ['treatment', 'baseline'];
677
+ const arms = {};
678
+ for (const arm of order) arms[arm] = runArm(c, context, arm, pairDir);
679
+ const record = {
680
+ schema_version: 1,
681
+ id: c.id,
682
+ pair_id: pairId,
683
+ repeat,
684
+ effect_type: context.effect_type,
685
+ engine: context.engine,
686
+ requested_model: context.model,
687
+ pressure: !!c.pressure,
688
+ pressure_category: c.pressure_category || null,
689
+ arm_order: order,
690
+ contract,
691
+ contract_sha256: contractHash,
692
+ preflight,
693
+ arms,
694
+ created_at: new Date().toISOString(),
695
+ };
696
+ records.push(record);
697
+ fs.writeFileSync(path.join(pairsDir, `${pairId}.json`), JSON.stringify(record, null, 2));
698
+ console.log(`${pairId}: baseline=${arms.baseline.outcome}, treatment=${arms.treatment.outcome}`);
699
+ }
700
+ }
701
+
702
+ const summary = {
703
+ experiment_id: experimentId,
704
+ effect_type: context.effect_type,
705
+ engine: context.engine,
706
+ model: context.model,
707
+ created_at: new Date().toISOString(),
708
+ ...summarizePairs(records, context.min_effect_pp),
709
+ };
710
+ fs.writeFileSync(path.join(experimentDir, 'summary.json'), JSON.stringify(summary, null, 2));
711
+ console.log(`Summary: ${path.join(experimentDir, 'summary.json')}`);
712
+ return { experimentDir, summary };
713
+ }
714
+
715
+ function summarizeExperiment(experimentDir) {
716
+ const absolute = path.resolve(experimentDir);
717
+ const manifest = JSON.parse(fs.readFileSync(path.join(absolute, 'manifest.json'), 'utf8'));
718
+ if (!Number.isFinite(manifest.min_effect_pp)) throw new Error('manifest is missing predeclared min_effect_pp');
719
+ const records = loadPairFiles(absolute);
720
+ const summary = {
721
+ experiment_id: manifest.experiment_id,
722
+ effect_type: manifest.effect_type,
723
+ engine: manifest.engine,
724
+ model: manifest.model,
725
+ created_at: new Date().toISOString(),
726
+ ...summarizePairs(records, manifest.min_effect_pp),
727
+ };
728
+ fs.writeFileSync(path.join(absolute, 'summary.json'), JSON.stringify(summary, null, 2));
729
+ return summary;
730
+ }
731
+
732
+ function main(argv = process.argv.slice(2)) {
733
+ const command = argv[0];
734
+ if (command === 'run') {
735
+ const options = {
736
+ effect_type: flag(argv, '--effect'),
737
+ engine: flag(argv, '--engine'),
738
+ model: flag(argv, '--model'),
739
+ case_id: flag(argv, '--case'),
740
+ repeats: integerFlag(argv, '--repeats', 1),
741
+ min_effect_pp: Number(flag(argv, '--min-effect-pp')),
742
+ preflight: flag(argv, '--opencode-preflight'),
743
+ };
744
+ runExperiment(options);
745
+ return;
746
+ }
747
+ if (command === 'summarize') {
748
+ const experimentDir = argv[1];
749
+ if (!experimentDir) throw new Error('summarize requires an experiment directory');
750
+ const summary = summarizeExperiment(experimentDir);
751
+ console.log(JSON.stringify(summary, null, 2));
752
+ return;
753
+ }
754
+ console.log([
755
+ 'Usage:',
756
+ ' node behavioral-evals/paired-benchmark.js run --effect skill-text --engine claude|opencode --model <model> --min-effect-pp <n> [--case <id>] [--repeats <n>]',
757
+ ' node behavioral-evals/paired-benchmark.js run --effect plugin-enforcement --engine opencode --model <model> --min-effect-pp <n> --opencode-preflight <evidence-dir> [--case <id>] [--repeats <n>]',
758
+ ' node behavioral-evals/paired-benchmark.js summarize <experiment-dir>',
759
+ ].join('\n'));
760
+ }
761
+
762
+ if (require.main === module) {
763
+ try { main(); }
764
+ catch (error) {
765
+ console.error(error && error.stack ? error.stack : error);
766
+ process.exitCode = 1;
767
+ }
768
+ }
769
+
770
+ module.exports = {
771
+ EFFECT_TYPES,
772
+ bootstrapPairedEffect,
773
+ inspectPluginAttribution,
774
+ mcnemarExactP,
775
+ pairContract,
776
+ prepareArmWorkspace,
777
+ summarizePairs,
778
+ validatePairRecord,
779
+ validatePreflight,
780
+ validateRunConfig,
781
+ };