harness-everything 0.26.0 → 0.28.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.
Files changed (34) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +35 -0
  4. package/behavioral-evals/README.md +39 -0
  5. package/behavioral-evals/case-validator.js +34 -0
  6. package/behavioral-evals/cases/baseline-worktree-submodule-reachability.yaml +30 -0
  7. package/behavioral-evals/fixtures/setup/fail.js +3 -0
  8. package/behavioral-evals/fixtures/setup/noop.js +4 -0
  9. package/behavioral-evals/fixtures/setup/verify-worktree-submodule.js +46 -0
  10. package/behavioral-evals/fixtures/setup/worktree-submodule.js +86 -0
  11. package/behavioral-evals/paired-benchmark.js +39 -5
  12. package/behavioral-evals/run-with-plugin.js +53 -39
  13. package/behavioral-evals/run.js +88 -9
  14. package/ci/mechanism-2j-behavioral-runner.test.js +121 -1
  15. package/ci/mechanism-2j-fixture-entrypoints.test.js +153 -0
  16. package/ci/mechanism-34-worktree-submodule-reachability.test.js +307 -0
  17. package/ci/mutation-check.js +2 -0
  18. package/ci/mutation-loader.js +4 -0
  19. package/ci/test-behavioral-case-validation.js +26 -0
  20. package/git-commit/SKILL.md +2 -2
  21. package/git-commit/guides/SUBMODULES.md +54 -1
  22. package/opencode-plugin/plugin.json +1 -1
  23. package/package.json +1 -1
  24. package/plugins/harness-everything/.codex-plugin/plugin.json +1 -1
  25. package/plugins/harness-everything/plugin.json +1 -1
  26. package/plugins/harness-everything/skills/git-commit/SKILL.md +2 -2
  27. package/plugins/harness-everything/skills/git-commit/guides/SUBMODULES.md +54 -1
  28. package/plugins/harness-everything/skills/using-git-worktrees/SKILL.md +1 -1
  29. package/plugins/harness-everything/skills/using-git-worktrees/references/submodules-in-worktrees.md +11 -1
  30. package/plugins/harness-everything/skills/using-git-worktrees/scripts/submodule-reachability.js +265 -0
  31. package/sbom.cdx.json +7 -7
  32. package/using-git-worktrees/SKILL.md +1 -1
  33. package/using-git-worktrees/references/submodules-in-worktrees.md +11 -1
  34. package/using-git-worktrees/scripts/submodule-reachability.js +265 -0
@@ -22,12 +22,13 @@ const fs = require('fs');
22
22
  const os = require('os');
23
23
  const path = require('path');
24
24
  const crypto = require('crypto');
25
- const { execFileSync } = require('child_process');
25
+ const { execFileSync, spawnSync } = require('child_process');
26
26
  const {
27
27
  gradeExecutionEvidence,
28
28
  parseTranscriptFile,
29
29
  } = require('./transcript-parser');
30
30
  const { commandMatches, normalizeRepoPath } = require('../scripts/lib/execution-contract');
31
+ const { resolveFixtureSetup, validateFixture } = require('./case-validator');
31
32
 
32
33
  const ROOT = path.resolve(__dirname, '..');
33
34
  const CASES_DIR = path.join(__dirname, 'cases');
@@ -155,7 +156,8 @@ function validate(cases) {
155
156
  if (!c.id) problems.push('missing id');
156
157
  if (!c.prompt) problems.push('missing prompt');
157
158
  if (!c.max_turns) problems.push('missing max_turns');
158
- if (!c.fixture || !Array.isArray(c.fixture.files)) problems.push('fixture.files missing');
159
+ if (!c.fixture) problems.push('fixture missing');
160
+ else problems.push(...validateFixture(c.fixture));
159
161
  if (!Array.isArray(c.expectations) || c.expectations.length === 0) problems.push('expectations missing');
160
162
  if (c.loaded_skills !== undefined && (!Array.isArray(c.loaded_skills) || c.loaded_skills.some(skill => !fs.existsSync(path.join(ROOT, skill, 'SKILL.md'))))) {
161
163
  problems.push('loaded_skills must name existing skills');
@@ -207,6 +209,66 @@ function buildWorkspace(c) {
207
209
  return ws;
208
210
  }
209
211
 
212
+ function runFixtureSetup(c, ws) {
213
+ const resolved = resolveFixtureSetup(c && c.fixture && c.fixture.setup);
214
+ if (!resolved) return { path: null, script_sha256: null, status: 'not-requested', exit_code: null, duration_ms: 0 };
215
+ const scriptSha256 = sha256(fs.readFileSync(resolved.absolute));
216
+ const startedAt = Date.now();
217
+ const result = spawnSync(process.execPath, [resolved.absolute], {
218
+ cwd: ws,
219
+ encoding: 'utf8',
220
+ env: {
221
+ ...process.env,
222
+ HARNESS_BEHAVIORAL_WORKSPACE: ws,
223
+ HARNESS_BEHAVIORAL_CASE_ID: String(c.id || ''),
224
+ },
225
+ timeout: 60 * 1000,
226
+ windowsHide: true,
227
+ });
228
+ const evidence = {
229
+ path: resolved.relative,
230
+ script_sha256: scriptSha256,
231
+ status: result.status === 0 && !result.error ? 'pass' : 'fail',
232
+ exit_code: result.status,
233
+ duration_ms: Date.now() - startedAt,
234
+ };
235
+ if (result.error || result.status !== 0) {
236
+ const detail = (result.stderr || result.stdout || (result.error && result.error.message) || '').trim();
237
+ const error = new Error(
238
+ `fixture setup failed: ${resolved.relative}${result.status === null ? '' : ` (exit ${result.status})`}${detail ? `: ${detail.slice(0, 300)}` : ''}`
239
+ );
240
+ error.fixtureSetup = evidence;
241
+ throw error;
242
+ }
243
+ return evidence;
244
+ }
245
+
246
+ function prepareWorkspace(c) {
247
+ let workspace = null;
248
+ try {
249
+ workspace = buildWorkspace(c);
250
+ return {
251
+ ok: true,
252
+ workspace,
253
+ setup: runFixtureSetup(c, workspace),
254
+ };
255
+ } catch (error) {
256
+ return {
257
+ ok: false,
258
+ workspace,
259
+ outcome: 'fixture-error',
260
+ error: error.message.slice(0, 500),
261
+ setup: error.fixtureSetup || {
262
+ path: c && c.fixture ? c.fixture.setup || null : null,
263
+ script_sha256: null,
264
+ status: 'fail',
265
+ exit_code: null,
266
+ duration_ms: null,
267
+ },
268
+ };
269
+ }
270
+ }
271
+
210
272
  // Capture the requested fixture baseline after treatment installation so the
211
273
  // installer's own artifacts (.claude/, .harness-src/, opencode.json) show up
212
274
  // in neither arm's post-run scope check.
@@ -685,9 +747,8 @@ function treatmentSkills(c) {
685
747
  function runArm(c, engine, arm, pairId) {
686
748
  const loadedSkills = arm === 'treatment' ? treatmentSkills(c) : [];
687
749
  const attributionRequired = arm === 'treatment';
688
- const ws = buildWorkspace(c);
689
- if (arm === 'treatment') installHarness(ws, loadedSkills);
690
- if (c.fixture.git) gitSnapshot(ws);
750
+ const prepared = prepareWorkspace(c);
751
+ const ws = prepared.workspace;
691
752
 
692
753
  const record = {
693
754
  arm,
@@ -698,9 +759,22 @@ function runArm(c, engine, arm, pairId) {
698
759
  workspace: ws,
699
760
  fixture_sha256: sha256(JSON.stringify(c.fixture)),
700
761
  prompt_sha256: sha256(c.prompt),
762
+ fixture_setup: prepared.setup,
701
763
  cost: null,
702
764
  };
765
+ if (!prepared.ok) {
766
+ return {
767
+ ...record,
768
+ outcome: 'fixture-error',
769
+ error: prepared.error,
770
+ parse_status: 'unavailable',
771
+ tool_call_count: null,
772
+ tool_call_counts: { attempted: null, completed: null, denied: null, unresolved: null },
773
+ };
774
+ }
703
775
  try {
776
+ if (arm === 'treatment') installHarness(ws, loadedSkills);
777
+ if (c.fixture.git) gitSnapshot(ws);
704
778
  const transcriptPath = runHeadless(c.prompt, ws, c.max_turns, engine, arm);
705
779
  const graded = grade(c, ws, transcriptPath, engine);
706
780
  const { results, passed } = graded;
@@ -764,6 +838,8 @@ function summarizePairResults(pairResults) {
764
838
  const completed = pairResults.filter(isCompletedPair);
765
839
  const sessionFailures = pairResults.filter(record => Object.values(record.arms || {})
766
840
  .some(arm => arm.outcome === 'session-error')).length;
841
+ const fixtureFailures = pairResults.filter(record => Object.values(record.arms || {})
842
+ .some(arm => arm.outcome === 'fixture-error')).length;
767
843
  const effective = completed.filter(record => record.verdict === 'EFFECTIVE').length;
768
844
  const categorySummary = {};
769
845
  for (const record of pairResults) {
@@ -806,6 +882,7 @@ function summarizePairResults(pairResults) {
806
882
  cost,
807
883
  completed_pairs: completed.length,
808
884
  session_failures: sessionFailures,
885
+ fixture_failures: fixtureFailures,
809
886
  tool_call_delta: toolCallDelta,
810
887
  verdicts: Object.fromEntries(['EFFECTIVE', 'INEFFECTIVE', 'INCONCLUSIVE', 'HARMFUL']
811
888
  .map(verdict => [verdict, completed.filter(record => record.verdict === verdict).length])),
@@ -835,7 +912,7 @@ function runLive(filter, engineArg, armArg = 'treatment') {
835
912
  fs.mkdirSync(RESULTS_DIR, { recursive: true });
836
913
 
837
914
  const pairResults = [];
838
- let sessionFailures = 0;
915
+ let infrastructureFailures = 0;
839
916
  for (const c of cases) {
840
917
  const pairId = `${c.id}-${Date.now()}-${crypto.randomBytes(3).toString('hex')}`;
841
918
  const order = armArg === 'both'
@@ -864,13 +941,13 @@ function runLive(filter, engineArg, armArg = 'treatment') {
864
941
  for (const arm of Object.values(arms)) {
865
942
  console.log(` ${arm.arm}: ${arm.outcome} (${arm.loaded_skills.length ? arm.loaded_skills.join(', ') : 'no Harness skills'})`);
866
943
  }
867
- if (Object.values(arms).some(arm => arm.outcome === 'session-error')) sessionFailures++;
944
+ if (Object.values(arms).some(arm => arm.outcome === 'session-error' || arm.outcome === 'fixture-error')) infrastructureFailures++;
868
945
  if (armArg === 'both') console.log(` Verdict: ${record.verdict}`);
869
946
  const suffix = armArg === 'both' ? 'pair' : armArg;
870
947
  fs.writeFileSync(path.join(RESULTS_DIR, `${record.date.slice(0, 10)}-${c.id}-${suffix}.json`), JSON.stringify(record, null, 2));
871
948
  }
872
949
 
873
- let exitCode = sessionFailures ? 1 : 0;
950
+ let exitCode = infrastructureFailures ? 1 : 0;
874
951
  if (armArg === 'both') {
875
952
  const aggregate = summarizePairResults(pairResults);
876
953
  const summary = {
@@ -888,7 +965,7 @@ function runLive(filter, engineArg, armArg = 'treatment') {
888
965
  fs.writeFileSync(summaryPath, JSON.stringify(summary, null, 2));
889
966
  console.log(`\nPaired summary: ${summaryPath}`);
890
967
  console.log(`Completed pairs: ${summary.completed_pairs}/${summary.requested_sample_size}`);
891
- exitCode = aggregate.session_failures ? 1 : 0;
968
+ exitCode = (aggregate.session_failures || aggregate.fixture_failures) ? 1 : 0;
892
969
  }
893
970
  process.exit(exitCode);
894
971
  }
@@ -915,6 +992,8 @@ if (require.main === module) main();
915
992
  module.exports = {
916
993
  buildEngineInvocation,
917
994
  buildWorkspace,
995
+ runFixtureSetup,
996
+ prepareWorkspace,
918
997
  countToolCalls,
919
998
  extractAgentEvents,
920
999
  extractAgentTrace,
@@ -2,7 +2,8 @@ const helper = require('./test-helper');
2
2
  const fs = require('fs');
3
3
  const os = require('os');
4
4
  const path = require('path');
5
- const { buildEngineInvocation, buildWorkspace } = require('../behavioral-evals/run');
5
+ const { spawnSync } = require('child_process');
6
+ const { buildEngineInvocation, buildWorkspace, runFixtureSetup, prepareWorkspace, summarizePairResults } = require('../behavioral-evals/run');
6
7
 
7
8
  console.log('\n[2j] Behavioral runner argv integrity...');
8
9
  const prompt = "Add punctuation stripping to slug.js, then run npm test. Do not truncate this request.";
@@ -59,4 +60,123 @@ helper.check(
59
60
  traversalError && traversalError.stack ? traversalError.stack : traversalTarget
60
61
  );
61
62
 
63
+ let setupWorkspace = null;
64
+ try {
65
+ setupWorkspace = buildWorkspace({
66
+ id: 'fixture-setup-valid',
67
+ fixture: {
68
+ files: [{ path: 'seed.txt', content: 'seed' }],
69
+ setup: 'behavioral-evals/fixtures/setup/noop.js',
70
+ },
71
+ });
72
+ const setupResult = runFixtureSetup({
73
+ id: 'fixture-setup-valid',
74
+ fixture: { setup: 'behavioral-evals/fixtures/setup/noop.js' },
75
+ }, setupWorkspace);
76
+ helper.check(
77
+ '2j. fixture setup runs via Node in the generated workspace',
78
+ setupResult.status === 'pass' &&
79
+ fs.readFileSync(path.join(setupWorkspace, '.fixture-setup-ok'), 'utf8') === 'ok\n',
80
+ JSON.stringify(setupResult)
81
+ );
82
+ } catch (error) {
83
+ helper.check('2j. fixture setup runs via Node in the generated workspace', false, error && error.stack ? error.stack : String(error));
84
+ } finally {
85
+ if (setupWorkspace) fs.rmSync(setupWorkspace, { recursive: true, force: true });
86
+ }
87
+
88
+ let failedPreparation = null;
89
+ try {
90
+ failedPreparation = prepareWorkspace({
91
+ id: 'fixture-setup-fails',
92
+ fixture: {
93
+ files: [{ path: 'seed.txt', content: 'seed' }],
94
+ setup: 'behavioral-evals/fixtures/setup/fail.js',
95
+ },
96
+ });
97
+ } catch (error) {
98
+ failedPreparation = { threw: error && error.stack ? error.stack : String(error) };
99
+ }
100
+ helper.check(
101
+ '2j. setup failure is classified as fixture-error before any model session',
102
+ Boolean(
103
+ failedPreparation &&
104
+ failedPreparation.ok === false &&
105
+ failedPreparation.outcome === 'fixture-error' &&
106
+ failedPreparation.setup &&
107
+ failedPreparation.setup.status === 'fail' &&
108
+ failedPreparation.setup.exit_code === 17
109
+ ),
110
+ JSON.stringify(failedPreparation)
111
+ );
112
+ if (failedPreparation && failedPreparation.workspace) {
113
+ fs.rmSync(failedPreparation.workspace, { recursive: true, force: true });
114
+ }
115
+
116
+ let topologyPreparation = null;
117
+ try {
118
+ topologyPreparation = prepareWorkspace({
119
+ id: 'worktree-submodule-setup-smoke',
120
+ fixture: {
121
+ files: [{ path: 'README.md', content: 'topology smoke' }],
122
+ setup: 'behavioral-evals/fixtures/setup/worktree-submodule.js',
123
+ },
124
+ });
125
+ const linkedSub = topologyPreparation.ok
126
+ ? path.join(topologyPreparation.workspace, 'super-wt', 'libs', 'sub')
127
+ : null;
128
+ const branchResult = linkedSub
129
+ ? spawnSync('git', ['-C', linkedSub, 'branch', '--show-current'], { encoding: 'utf8', windowsHide: true })
130
+ : null;
131
+ const helperResult = topologyPreparation.ok
132
+ ? spawnSync(process.execPath, [
133
+ path.join(topologyPreparation.workspace, '.fixture-tools', 'submodule-reachability.js'),
134
+ '--json', '--root', 'super-wt',
135
+ ], { cwd: topologyPreparation.workspace, encoding: 'utf8', windowsHide: true })
136
+ : null;
137
+ helper.check(
138
+ '2j. worktree/submodule setup creates the real #243 topology before model execution',
139
+ Boolean(
140
+ topologyPreparation.ok &&
141
+ fs.existsSync(path.join(topologyPreparation.workspace, 'super-wt', '.git')) &&
142
+ fs.existsSync(path.join(linkedSub, '.git')) &&
143
+ branchResult && branchResult.status === 0 && branchResult.stdout.trim() === '' &&
144
+ helperResult && helperResult.status === 0
145
+ ),
146
+ JSON.stringify({
147
+ preparation: topologyPreparation,
148
+ branchStatus: branchResult && branchResult.status,
149
+ branch: branchResult && branchResult.stdout,
150
+ helperStatus: helperResult && helperResult.status,
151
+ helperStderr: helperResult && helperResult.stderr,
152
+ })
153
+ );
154
+ } catch (error) {
155
+ helper.check(
156
+ '2j. worktree/submodule setup creates the real #243 topology before model execution',
157
+ false,
158
+ error && error.stack ? error.stack : String(error)
159
+ );
160
+ } finally {
161
+ if (topologyPreparation && topologyPreparation.workspace) {
162
+ fs.rmSync(topologyPreparation.workspace, { recursive: true, force: true });
163
+ }
164
+ }
165
+
166
+ const fixtureFailureSummary = summarizePairResults([{
167
+ id: 'fixture-error-pair',
168
+ pressure_category: null,
169
+ arms: {
170
+ baseline: { outcome: 'fixture-error', cost: null, tool_call_count: null },
171
+ treatment: { outcome: 'pass', cost: null, tool_call_count: 1 },
172
+ },
173
+ }]);
174
+ helper.check(
175
+ '2j. fixture errors remain distinct aggregate infrastructure failures',
176
+ fixtureFailureSummary.fixture_failures === 1 &&
177
+ fixtureFailureSummary.session_failures === 0 &&
178
+ fixtureFailureSummary.completed_pairs === 0,
179
+ JSON.stringify(fixtureFailureSummary)
180
+ );
181
+
62
182
  helper.finish();
@@ -0,0 +1,153 @@
1
+ 'use strict';
2
+
3
+ const assert = require('assert');
4
+ const fs = require('fs');
5
+ const os = require('os');
6
+ const path = require('path');
7
+ const { spawnSync } = require('child_process');
8
+ const { parseSimpleYaml, prepareWorkspace } = require('../behavioral-evals/run');
9
+ const paired = require('../behavioral-evals/paired-benchmark');
10
+ const ROOT = path.resolve(__dirname, '..');
11
+ const topology = parseSimpleYaml(fs.readFileSync(path.join(ROOT, 'behavioral-evals/cases/baseline-worktree-submodule-reachability.yaml'), 'utf8'));
12
+ const failure = { ...topology, fixture: { ...topology.fixture, setup: 'behavioral-evals/fixtures/setup/fail.js' } };
13
+ const paths = [];
14
+ const topologyFingerprints = [];
15
+ const setupHashes = [];
16
+ function track(ws) { if (ws) paths.push(ws); return ws; }
17
+ function gitText(cwd, args) {
18
+ const result = spawnSync('git', args, { cwd, encoding: 'utf8', windowsHide: true });
19
+ assert.strictEqual(result.status, 0, result.stderr || result.stdout);
20
+ return result.stdout.trim();
21
+ }
22
+ function topologyFingerprint(ws) {
23
+ return {
24
+ child_head: gitText(path.join(ws, 'child-origin'), ['rev-parse', 'HEAD']),
25
+ super_head: gitText(path.join(ws, 'super'), ['rev-parse', 'HEAD']),
26
+ linked_head: gitText(path.join(ws, 'super-wt'), ['rev-parse', 'HEAD']),
27
+ gitlink: gitText(path.join(ws, 'super-wt'), ['rev-parse', 'HEAD:libs/sub']),
28
+ };
29
+ }
30
+ function assertTopology(ws) {
31
+ assert.ok(fs.existsSync(path.join(ws, 'super-wt', '.git')), 'setup must construct the linked worktree');
32
+ assert.ok(fs.existsSync(path.join(ws, '.fixture-tools', 'verify-worktree-submodule.js')), 'setup must install the grader');
33
+ }
34
+ try {
35
+ for (const arm of ['baseline', 'treatment']) {
36
+ for (const effect of ['skill-text', 'plugin-enforcement']) {
37
+ const prepared = paired.prepareArmWorkspace(topology, effect, arm, 'opencode');
38
+ track(prepared.ws);
39
+ assertTopology(prepared.ws);
40
+ assert.strictEqual(prepared.fixture_setup.status, 'pass');
41
+ assert.match(prepared.fixture_setup.script_sha256, /^[0-9a-f]{64}$/);
42
+ topologyFingerprints.push(topologyFingerprint(prepared.ws));
43
+ setupHashes.push(prepared.fixture_setup.script_sha256);
44
+ const failed = paired.prepareArmWorkspace(failure, effect, arm, 'opencode');
45
+ track(failed.ws);
46
+ assert.strictEqual(failed.ok, false);
47
+ assert.strictEqual(failed.outcome, 'fixture-error');
48
+ assert.strictEqual(failed.fixture_setup.exit_code, 17);
49
+ assert.ok(!fs.existsSync(path.join(failed.ws, '.claude')), 'setup failure must precede skill installation');
50
+ assert.ok(!fs.existsSync(path.join(failed.ws, '.opencode')), 'setup failure must precede plugin installation');
51
+ }
52
+ }
53
+ assert.ok(topologyFingerprints.length >= 4);
54
+ for (const fingerprint of topologyFingerprints.slice(1)) {
55
+ assert.deepStrictEqual(fingerprint, topologyFingerprints[0], 'fixture setup must produce identical Git object identities across independent arms/runs');
56
+ }
57
+ assert.strictEqual(new Set(setupHashes).size, 1, 'all arms must retain the same checked-in setup script hash');
58
+
59
+ const pairDir = track(fs.mkdtempSync(path.join(os.tmpdir(), 'fixture-entrypoints-')));
60
+ let launches = 0;
61
+ const context = { effect_type: 'skill-text', engine: 'claude', model: 'test' };
62
+ const arms = {};
63
+ for (const arm of ['baseline', 'treatment']) {
64
+ arms[arm] = paired.runArm(failure, context, arm, pairDir, () => { launches++; throw new Error('model must not start'); });
65
+ assert.strictEqual(arms[arm].outcome, 'fixture-error');
66
+ assert.strictEqual(arms[arm].fixture_setup.exit_code, 17);
67
+ assert.strictEqual(arms[arm].tool_call_count, null);
68
+ }
69
+ assert.strictEqual(launches, 0);
70
+ const summary = paired.summarizePairs([{ id: topology.id, arms }], 10);
71
+ assert.strictEqual(summary.fixture_failures, 1);
72
+ assert.strictEqual(summary.included_pairs, 0);
73
+ assert.deepStrictEqual(summary.arm_outcomes, { baseline: { pass: 0, fail: 0 }, treatment: { pass: 0, fail: 0 } });
74
+
75
+ const plugin = require('../behavioral-evals/run-with-plugin');
76
+ const failed = plugin.runCase(failure, () => { launches++; throw new Error('model must not start'); });
77
+ track(failed.workspace);
78
+ assert.strictEqual(failed.outcome, 'fixture-error');
79
+ assert.strictEqual(failed.fixture_setup.exit_code, 17);
80
+ assert.strictEqual(launches, 0);
81
+ assert.ok(!fs.existsSync(path.join(failed.workspace, '.harness-src')), 'failed setup must precede Harness installation');
82
+
83
+ // Exercise the same public case path as the plugin CLI, without calling a model.
84
+ const result = plugin.runCase(topology, (_prompt, ws) => {
85
+ launches++;
86
+ assertTopology(ws);
87
+ const transcript = path.join(pairDir, 'plugin-transcript.jsonl');
88
+ fs.writeFileSync(transcript, '');
89
+ return transcript;
90
+ });
91
+ track(result.workspace);
92
+ assert.strictEqual(launches, 1);
93
+ assert.strictEqual(result.fixture_setup.status, 'pass');
94
+ assert.strictEqual(result.outcome, 'fail', 'an untouched topology must fail the actual final-state graders');
95
+
96
+ for (const fixture of [
97
+ { files: [{ path: '../escape', content: 'bad' }] },
98
+ { files: [{ path: 'seed', content: 'ok' }], setup: '../escape.js' },
99
+ ]) {
100
+ const result = prepareWorkspace({ id: 'invalid-fixture', fixture });
101
+ track(result.workspace);
102
+ assert.strictEqual(result.outcome, 'fixture-error');
103
+ }
104
+ // The public CLIs must retain fixture-error evidence and exit nonzero.
105
+ const caseId = `fixture-entrypoints-failure-${process.pid}`;
106
+ const casePath = path.join(ROOT, 'behavioral-evals/cases', `${caseId}.yaml`);
107
+ fs.writeFileSync(casePath, `id: ${caseId}
108
+ name: Fixture failure CLI control
109
+ loaded_skills:
110
+ - git-commit
111
+ fixture:
112
+ files:
113
+ - path: seed.txt
114
+ content: seed
115
+ setup: behavioral-evals/fixtures/setup/fail.js
116
+ prompt: This model session must never start.
117
+ max_turns: 1
118
+ expectations:
119
+ - type: file_contains
120
+ path: seed.txt
121
+ value: seed
122
+ `);
123
+ paths.push(casePath);
124
+ const pairedCli = spawnSync(process.execPath, [
125
+ path.join(ROOT, 'behavioral-evals/paired-benchmark.js'), 'run',
126
+ '--effect', 'skill-text', '--engine', 'claude', '--model', 'fixture-test',
127
+ '--min-effect-pp', '10', '--case', caseId,
128
+ ], { cwd: ROOT, encoding: 'utf8', timeout: 30000 });
129
+ const summaryMatch = pairedCli.stdout.match(/Summary: (.+)/);
130
+ assert.ok(summaryMatch, pairedCli.stderr || pairedCli.stdout);
131
+ const summaryPath = summaryMatch[1].trim();
132
+ track(path.dirname(summaryPath));
133
+ assert.strictEqual(pairedCli.status, 1, pairedCli.stderr);
134
+ const cliSummary = JSON.parse(fs.readFileSync(summaryPath, 'utf8'));
135
+ assert.strictEqual(cliSummary.fixture_failures, 1);
136
+ assert.strictEqual(cliSummary.included_pairs, 0);
137
+ const pluginCli = spawnSync(process.execPath, [
138
+ path.join(ROOT, 'behavioral-evals/run-with-plugin.js'), 'run', '--case', caseId,
139
+ ], { cwd: ROOT, encoding: 'utf8', timeout: 30000 });
140
+ assert.strictEqual(pluginCli.status, 1, pluginCli.stderr);
141
+ const resultMatch = pluginCli.stdout.match(/fixture-error -> (.+)/);
142
+ assert.ok(resultMatch, pluginCli.stdout);
143
+ const resultPath = resultMatch[1].trim();
144
+ paths.push(resultPath);
145
+ const pluginFailure = JSON.parse(fs.readFileSync(resultPath, 'utf8'));
146
+ track(pluginFailure.workspace);
147
+ assert.strictEqual(pluginFailure.fixture_setup.exit_code, 17);
148
+ assert.ok(!fs.existsSync(path.join(pluginFailure.workspace, '.harness-src')));
149
+
150
+ console.log('Fixture entrypoints: paired baseline/treatment and plugin execute setup; failures skip installation/model and are excluded from scores.');
151
+ } finally {
152
+ for (const dir of paths) fs.rmSync(dir, { recursive: true, force: true });
153
+ }