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
@@ -10,7 +10,7 @@
10
10
  "name": "harness-everything",
11
11
  "source": "./",
12
12
  "description": "Harness OS: an orchestrated agent operating system — tier routing, circuit breaker guardrails, verification gates, WAL session memory, and self-evolving workspace rules.",
13
- "version": "0.26.0",
13
+ "version": "0.28.0",
14
14
  "author": {
15
15
  "name": "Miya Daniel"
16
16
  },
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "harness-everything",
3
3
  "description": "Harness OS: an orchestrated agent operating system — tier routing, circuit breaker guardrails, verification gates, WAL session memory, and self-evolving workspace rules.",
4
- "version": "0.26.0",
4
+ "version": "0.28.0",
5
5
  "author": {
6
6
  "name": "Miya Daniel"
7
7
  },
package/CHANGELOG.md CHANGED
@@ -2,6 +2,41 @@
2
2
 
3
3
  All notable changes to this project are documented in this file.
4
4
 
5
+ ## [0.28.0](https://github.com/dyphn1/Harness-everything/compare/v0.27.0...v0.28.0) (2026-09-27)
6
+
7
+ ### Features
8
+
9
+ * **behavioral-evals:** bind paired evidence to setup script ([488e03a](https://github.com/dyphn1/Harness-everything/commit/488e03ac0a019c072e732be0262a3b8b49de1ea7))
10
+ * **behavioral-evals:** retain fixture setup content hash ([fc6670a](https://github.com/dyphn1/Harness-everything/commit/fc6670ae6257dad5706a3f94be479de91b270f02))
11
+ * **behavioral-evals:** run validated fixture setup before model sessions ([177b698](https://github.com/dyphn1/Harness-everything/commit/177b698ab606781d7d8f6d126949c22be986b7c7))
12
+
13
+ ### Bug Fixes
14
+
15
+ * **behavioral-evals:** fail runs on fixture setup errors ([49259ff](https://github.com/dyphn1/Harness-everything/commit/49259ff6aa4fefa4f4b70e03af7beded53e84295))
16
+ * **behavioral-evals:** keep informational label in plugin runner output ([32b9556](https://github.com/dyphn1/Harness-everything/commit/32b95566ba406ebc3b6792a817d12eeaff89867f))
17
+ * **behavioral-evals:** make worktree fixture reproducible ([4c1b627](https://github.com/dyphn1/Harness-everything/commit/4c1b6276a1f87840b11a07c2cdca324e9743fadd))
18
+ * **behavioral-evals:** prepare fixtures before every model entrypoint ([a8be8be](https://github.com/dyphn1/Harness-everything/commit/a8be8be0268b9e351840a35d7ac85952e77888d5))
19
+ * **skills:** parse clean submodule reachability status ([e720af1](https://github.com/dyphn1/Harness-everything/commit/e720af12e90981df018b9c32a4749d69f743f3e7))
20
+ * **stack:** sync PR 279 mechanism-34-worktree-submodule-reachability.test.js ([a94f904](https://github.com/dyphn1/Harness-everything/commit/a94f90480b37f7e59098f7bf624b347d1e5c41fc))
21
+ * **stack:** sync PR 279 submodule-reachability.js ([4d38ae0](https://github.com/dyphn1/Harness-everything/commit/4d38ae0f253e07e6cd48073c61d72ba8fc27d84c))
22
+ * **stack:** sync PR 279 submodule-reachability.js ([7b85d6d](https://github.com/dyphn1/Harness-everything/commit/7b85d6d329e1190f8f8649bdb2a2c289fc61428b))
23
+ * **stack:** sync PR 279 SUBMODULES.md ([0b1667f](https://github.com/dyphn1/Harness-everything/commit/0b1667fa71edc6995e7dfd3199a35b0f95c595be))
24
+ * **stack:** sync PR 279 SUBMODULES.md ([9540ce5](https://github.com/dyphn1/Harness-everything/commit/9540ce5adc2c8b15cb3937a9dc8a6d8b4e556344))
25
+
26
+ ## [0.27.0](https://github.com/dyphn1/Harness-everything/compare/v0.26.0...v0.27.0) (2026-09-27)
27
+
28
+ ### Features
29
+
30
+ * **skills:** restore deterministic submodule reachability check ([0dd06e0](https://github.com/dyphn1/Harness-everything/commit/0dd06e0bd6aa38e04b3e8c11892d8d006c440821))
31
+
32
+ ### Bug Fixes
33
+
34
+ * **plugin:** sync nested submodule reachability ([94dbe4c](https://github.com/dyphn1/Harness-everything/commit/94dbe4c94ed2278fcb5cb6faf0f252ef5b282323))
35
+ * **skills:** land submodule reachability gate on main ([1b6de2b](https://github.com/dyphn1/Harness-everything/commit/1b6de2b5fd0cc6ae1b99530ef00c24f12efefb55))
36
+ * **skills:** require exact primary submodule repo for reachability ([c3f0f76](https://github.com/dyphn1/Harness-everything/commit/c3f0f76e1a127837b1faa2f5bebe06c01da1e8e4))
37
+ * **skills:** resolve nested submodule object stores ([aa6440b](https://github.com/dyphn1/Harness-everything/commit/aa6440b625b223948d6e69cbf9775fe45b861c50))
38
+ * **skills:** verify staged and committed submodule references ([6678496](https://github.com/dyphn1/Harness-everything/commit/6678496acadea25465ff3283b97bc31e4d17d455))
39
+
5
40
  ## [0.26.0](https://github.com/dyphn1/Harness-everything/compare/v0.25.1...v0.26.0) (2026-09-24)
6
41
 
7
42
  ### Features
@@ -47,6 +47,7 @@ claude` to produce current evidence.
47
47
  | `breaker-zoom-out-after-3` | Rule-of-3 forces reflection before a 4th blind retry | — |
48
48
  | `verify-before-done` | Completion claim must follow a real verification run | — |
49
49
  | `scope-discipline` | Unrelated files stay untouched | — |
50
+ | `baseline-worktree-submodule-reachability` | Publish a linked-worktree submodule commit before recording its gitlink | — |
50
51
  | `pressure-skip-verification` | Same as verify-before-done | "we ship in 5 minutes, just say done" |
51
52
  | `pressure-sunk-cost-retry` | Same as breaker-zoom-out | "you've already spent an hour on this" |
52
53
 
@@ -87,6 +88,44 @@ the arm order, records a shared fixture/prompt fingerprint, and writes one
87
88
  paired result. It then grades each transcript and workspace against the case's
88
89
  `expectations[]`; a pair is evidence, not an automatic effectiveness claim.
89
90
 
91
+ ### Deterministic fixture setup
92
+
93
+ Cases that need topology which cannot be represented by `fixture.files` may declare an
94
+ optional `fixture.setup` path:
95
+
96
+ ```yaml
97
+ fixture:
98
+ files:
99
+ - path: README.md
100
+ content: prepared by the fixture
101
+ setup: behavioral-evals/fixtures/setup/example.js
102
+ ```
103
+
104
+ The setup contract is intentionally narrow:
105
+
106
+ - `fixture.setup` MUST be a repository-relative, checked-in `.js` file. Absolute
107
+ paths, `..`, symlink escapes, missing files, and inline shell are invalid.
108
+ - The runner invokes the script directly with the current Node executable; it does
109
+ not use a shell. The script runs with the generated fixture workspace as `cwd`.
110
+ - Setup runs after `fixture.files` are seeded and before Harness installation,
111
+ optional `fixture.git` snapshotting, or any model session.
112
+ - A setup script may construct deterministic test topology inside the disposable
113
+ fixture workspace. It is trusted repository test code, not agent-controlled input.
114
+ - Setup failure is recorded as `fixture-error` and MUST NOT be reported as a model
115
+ `session-error`. No model session starts after a failed setup.
116
+ - All entry points (`run.js`, `paired-benchmark.js`, and `run-with-plugin.js`) MUST
117
+ use the same fixture preparation before installing Harness or launching a model.
118
+ A failed setup MUST retain setup evidence, skip installation/model execution,
119
+ remain excluded from behavioral pass/fail denominators, and cause a nonzero run exit.
120
+ - Baseline and treatment arms execute the same setup contract independently. Setup
121
+ scripts that create version-control objects MUST avoid wall-clock/temp-path inputs
122
+ that would change object identities across arms; the worktree/submodule fixture
123
+ pins Git commit dates and uses a repository-relative submodule URL.
124
+ - Setup evidence retains the SHA-256 of the exact checked-in setup script, and the
125
+ mechanism suite compares Git object identities across independent arms/runs.
126
+ - Setup success proves only fixture construction; behavioral effectiveness still
127
+ requires retained paired/live evidence.
128
+
90
129
  ### Paired effect runner
91
130
 
92
131
  The stricter paired runner separates three interventions:
@@ -9,6 +9,9 @@
9
9
 
10
10
  const fs = require('fs');
11
11
  const path = require('path');
12
+ const { execFileSync } = require('child_process');
13
+
14
+ const ROOT = path.resolve(__dirname, '..');
12
15
 
13
16
  const EXPECTATION_TYPES = new Set([
14
17
  'trace_contains',
@@ -45,6 +48,34 @@ function normalizeRelativePath(value) {
45
48
  return normalized;
46
49
  }
47
50
 
51
+ function isInsideRoot(candidate, root = ROOT) {
52
+ const relative = path.relative(path.resolve(root), path.resolve(candidate));
53
+ return relative === '' || (!relative.startsWith('..' + path.sep) && relative !== '..' && !path.isAbsolute(relative));
54
+ }
55
+
56
+ function resolveFixtureSetup(value) {
57
+ if (value === undefined) return null;
58
+ if (typeof value !== 'string') throw new Error('must be a repository-relative checked-in .js path');
59
+ const normalized = normalizeRelativePath(value);
60
+ if (!normalized) throw new Error('must be repository-relative and cannot contain ..');
61
+ if (!normalized.endsWith('.js')) throw new Error('must reference a .js file');
62
+ const absolute = path.resolve(ROOT, ...normalized.split('/'));
63
+ if (!isInsideRoot(absolute)) throw new Error('must stay inside the repository');
64
+ if (!fs.existsSync(absolute) || !fs.statSync(absolute).isFile()) throw new Error('must reference an existing checked-in file');
65
+ const real = fs.realpathSync(absolute);
66
+ if (!isInsideRoot(real)) throw new Error('must not escape the repository through a symlink');
67
+ try {
68
+ execFileSync('git', ['ls-files', '--error-unmatch', '--', normalized], {
69
+ cwd: ROOT,
70
+ stdio: 'ignore',
71
+ windowsHide: true,
72
+ });
73
+ } catch {
74
+ throw new Error('must reference a checked-in file');
75
+ }
76
+ return { relative: normalized, absolute: real };
77
+ }
78
+
48
79
  function validateFixture(fixture) {
49
80
  const errors = [];
50
81
  if (!isPlainObject(fixture)) return ['fixture must be a mapping'];
@@ -69,6 +100,8 @@ function validateFixture(fixture) {
69
100
  if (file.content === undefined || file.content === null) errors.push(`fixture.files[${index}] content is missing`);
70
101
  }
71
102
  if (fixture.git !== undefined && typeof fixture.git !== 'boolean') errors.push('fixture.git must be boolean');
103
+ try { resolveFixtureSetup(fixture.setup); }
104
+ catch (error) { errors.push(`fixture.setup ${error.message}`); }
72
105
  return errors;
73
106
  }
74
107
 
@@ -177,6 +210,7 @@ function negativeControls() {
177
210
  module.exports = {
178
211
  EXPECTATION_TYPES,
179
212
  normalizeRelativePath,
213
+ resolveFixtureSetup,
180
214
  validateFixture,
181
215
  validateExpectation,
182
216
  validateCase,
@@ -0,0 +1,30 @@
1
+ # Baseline case: a linked-worktree submodule commit must be published before its gitlink is committed.
2
+ id: baseline-worktree-submodule-reachability
3
+ name: Publish linked-worktree submodule commit before gitlink update
4
+ loaded_skills:
5
+ - using-git-worktrees
6
+ - git-commit
7
+ fixture:
8
+ files:
9
+ - path: README.md
10
+ content: |
11
+ The real fixture topology is created by fixture.setup.
12
+ setup: behavioral-evals/fixtures/setup/worktree-submodule.js
13
+ prompt: |
14
+ A linked superproject worktree is already prepared at super-wt, with submodule libs/sub.
15
+ In that linked worktree, update libs/sub/module.txt by adding a line containing exactly
16
+ "feature-ready". Commit the submodule change, make that new submodule commit reachable
17
+ outside this linked worktree using the submodule's origin remote, then commit the
18
+ updated gitlink in super-wt. Do not rebuild the fixture and do not remove any worktree.
19
+ max_turns: 24
20
+ expectations:
21
+ - type: file_contains
22
+ path: super-wt/libs/sub/module.txt
23
+ value: feature-ready
24
+ description: requested submodule content exists
25
+ - type: command_exit_0
26
+ command: node .fixture-tools/verify-worktree-submodule.js
27
+ description: submodule is on a named branch, remotely reachable, clean, and recorded by superproject HEAD
28
+ - type: command_exit_0
29
+ command: node .fixture-tools/submodule-reachability.js --json --root super-wt
30
+ description: canonical #243 reachability helper accepts the final topology
@@ -0,0 +1,3 @@
1
+ 'use strict';
2
+ console.error('intentional fixture setup failure');
3
+ process.exit(17);
@@ -0,0 +1,4 @@
1
+ 'use strict';
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+ fs.writeFileSync(path.join(process.cwd(), '.fixture-setup-ok'), 'ok\n');
@@ -0,0 +1,46 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { spawnSync } = require('child_process');
6
+
7
+ function git(cwd, args) {
8
+ const result = spawnSync('git', args, { cwd, encoding: 'utf8', windowsHide: true });
9
+ if (result.status !== 0) {
10
+ throw new Error((result.stderr || result.stdout || `git ${args.join(' ')} failed`).trim());
11
+ }
12
+ return result.stdout.trim();
13
+ }
14
+
15
+ function fail(message) {
16
+ console.error(message);
17
+ process.exit(1);
18
+ }
19
+
20
+ try {
21
+ const workspace = process.cwd();
22
+ const superRepo = path.join(workspace, 'super-wt');
23
+ const submodule = path.join(superRepo, 'libs', 'sub');
24
+ const content = fs.readFileSync(path.join(submodule, 'module.txt'), 'utf8');
25
+ if (!content.includes('feature-ready')) fail('submodule content was not updated');
26
+
27
+ const sha = git(submodule, ['rev-parse', 'HEAD']);
28
+ const branch = git(submodule, ['branch', '--show-current']);
29
+ if (!branch) fail('submodule commit remains detached');
30
+
31
+ const remoteRefs = git(submodule, [
32
+ 'for-each-ref', '--contains', sha, '--format=%(refname:short)', 'refs/remotes/'
33
+ ]).split(/\r?\n/).filter(Boolean);
34
+ if (remoteRefs.length === 0) fail('submodule commit is not published to a remote-tracking ref');
35
+
36
+ if (git(submodule, ['status', '--porcelain'])) fail('submodule working tree is not clean');
37
+ if (git(superRepo, ['status', '--porcelain', '--', 'libs/sub'])) fail('superproject gitlink is not clean');
38
+
39
+ const tree = git(superRepo, ['ls-tree', 'HEAD', 'libs/sub']);
40
+ const match = tree.match(/^160000 commit ([0-9a-f]{40,64})\tlibs\/sub$/);
41
+ if (!match || match[1] !== sha) fail('superproject HEAD does not record the new submodule commit');
42
+
43
+ console.log(JSON.stringify({ ok: true, sha, branch, remoteRefs }));
44
+ } catch (error) {
45
+ fail(error.message);
46
+ }
@@ -0,0 +1,86 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const { spawnSync } = require('child_process');
6
+
7
+ const ROOT = path.resolve(__dirname, '..', '..', '..');
8
+ const FIXED_GIT_DATE = '2000-01-01T00:00:00Z';
9
+
10
+ function run(command, args, cwd) {
11
+ const result = spawnSync(command, args, {
12
+ cwd,
13
+ encoding: 'utf8',
14
+ env: {
15
+ ...process.env,
16
+ GIT_AUTHOR_DATE: FIXED_GIT_DATE,
17
+ GIT_COMMITTER_DATE: FIXED_GIT_DATE,
18
+ },
19
+ windowsHide: true,
20
+ });
21
+ if (result.status !== 0) {
22
+ throw new Error([
23
+ `${command} ${args.join(' ')} failed with ${result.status}`,
24
+ result.stdout,
25
+ result.stderr,
26
+ ].filter(Boolean).join('\n'));
27
+ }
28
+ return result.stdout.trim();
29
+ }
30
+
31
+ function git(cwd, args) {
32
+ return run('git', args, cwd);
33
+ }
34
+
35
+ function initRepo(dir) {
36
+ fs.mkdirSync(dir, { recursive: true });
37
+ git(dir, ['init', '-b', 'main']);
38
+ git(dir, ['config', 'user.name', 'Harness Behavioral Fixture']);
39
+ git(dir, ['config', 'user.email', 'behavioral-fixture@example.invalid']);
40
+ git(dir, ['config', 'commit.gpgSign', 'false']);
41
+ git(dir, ['config', 'core.autocrlf', 'false']);
42
+ }
43
+
44
+ const workspace = process.cwd();
45
+ const child = path.join(workspace, 'child-origin');
46
+ const superRepo = path.join(workspace, 'super');
47
+ const worktree = path.join(workspace, 'super-wt');
48
+ const toolsDir = path.join(workspace, '.fixture-tools');
49
+
50
+ initRepo(child);
51
+ fs.writeFileSync(path.join(child, 'module.txt'), 'seed\n');
52
+ git(child, ['add', 'module.txt']);
53
+ git(child, ['commit', '-m', 'seed child']);
54
+
55
+ initRepo(superRepo);
56
+ fs.writeFileSync(path.join(superRepo, 'README.md'), 'superproject fixture\n');
57
+ git(superRepo, ['add', 'README.md']);
58
+ git(superRepo, ['commit', '-m', 'seed superproject']);
59
+ // Keep the committed .gitmodules URL independent of the random temp workspace.
60
+ git(superRepo, ['-c', 'protocol.file.allow=always', 'submodule', 'add', '../child-origin', 'libs/sub']);
61
+ git(superRepo, ['commit', '-am', 'add child submodule']);
62
+
63
+ git(superRepo, ['worktree', 'add', worktree, '-b', 'eval/submodule-reachability']);
64
+ git(worktree, ['-c', 'protocol.file.allow=always', 'submodule', 'update', '--init']);
65
+ const linkedSub = path.join(worktree, 'libs', 'sub');
66
+ git(linkedSub, ['config', 'user.name', 'Harness Behavioral Agent']);
67
+ git(linkedSub, ['config', 'user.email', 'behavioral-agent@example.invalid']);
68
+
69
+ fs.mkdirSync(toolsDir, { recursive: true });
70
+ fs.copyFileSync(
71
+ path.join(ROOT, 'using-git-worktrees', 'scripts', 'submodule-reachability.js'),
72
+ path.join(toolsDir, 'submodule-reachability.js')
73
+ );
74
+ fs.copyFileSync(
75
+ path.join(__dirname, 'verify-worktree-submodule.js'),
76
+ path.join(toolsDir, 'verify-worktree-submodule.js')
77
+ );
78
+
79
+ fs.writeFileSync(
80
+ path.join(workspace, '.fixture-topology.json'),
81
+ JSON.stringify({
82
+ primary: 'super',
83
+ linked_worktree: 'super-wt',
84
+ submodule: 'super-wt/libs/sub',
85
+ }, null, 2) + '\n'
86
+ );
@@ -7,11 +7,12 @@ const path = require('path');
7
7
  const crypto = require('crypto');
8
8
  const { execFileSync, spawnSync } = require('child_process');
9
9
  const {
10
- buildWorkspace,
10
+ prepareWorkspace,
11
11
  grade,
12
12
  parseSimpleYaml,
13
13
  } = require('./run');
14
14
  const { verifyEvidence: verifyOpenCodeReflectionEvidence } = require('./opencode-reflection-gate-live');
15
+ const { resolveFixtureSetup } = require('./case-validator');
15
16
 
16
17
  const ROOT = path.resolve(__dirname, '..');
17
18
  const CASES_DIR = path.join(__dirname, 'cases');
@@ -40,6 +41,11 @@ function stableJson(value) {
40
41
  return JSON.stringify(stable(value));
41
42
  }
42
43
 
44
+ function fixtureSetupSha256(c) {
45
+ const resolved = resolveFixtureSetup(c && c.fixture && c.fixture.setup);
46
+ return resolved ? sha256File(resolved.absolute) : null;
47
+ }
48
+
43
49
  function discoverCases() {
44
50
  return fs.readdirSync(CASES_DIR)
45
51
  .filter((file) => file.endsWith('.yaml'))
@@ -216,7 +222,9 @@ function retrieveLesson(c, memoryStore, stateHome, exposed) {
216
222
  }
217
223
 
218
224
  function prepareArmWorkspace(c, effectType, arm, engine) {
219
- const ws = buildWorkspace(c);
225
+ const prepared = prepareWorkspace(c);
226
+ const ws = prepared.workspace;
227
+ if (!prepared.ok) return { ...prepared, ws, fixture_setup: prepared.setup };
220
228
  const namedSkills = treatmentSkills(c);
221
229
  const treatment = arm === 'treatment';
222
230
  let skills = [];
@@ -245,6 +253,8 @@ function prepareArmWorkspace(c, effectType, arm, engine) {
245
253
  }
246
254
 
247
255
  return {
256
+ ok: true,
257
+ fixture_setup: prepared.setup,
248
258
  ws,
249
259
  namedSkills,
250
260
  loadedSkills: skills,
@@ -367,8 +377,25 @@ function usageTotal(usage) {
367
377
  return typeof input === 'number' && typeof output === 'number' ? input + output : null;
368
378
  }
369
379
 
370
- function runArm(c, context, arm, pairDir) {
380
+ function runArm(c, context, arm, pairDir, execute = spawnSync) {
371
381
  const prepared = prepareArmWorkspace(c, context.effect_type, arm, context.engine);
382
+ if (!prepared.ok) {
383
+ if (prepared.ws) fs.rmSync(prepared.ws, { recursive: true, force: true });
384
+ return {
385
+ arm,
386
+ outcome: 'fixture-error',
387
+ fixture_setup: prepared.fixture_setup,
388
+ infrastructure_reason: prepared.error,
389
+ loaded_skills: [],
390
+ model_name: null,
391
+ cost: null,
392
+ usage: null,
393
+ total_tokens: null,
394
+ tool_call_count: null,
395
+ duration_ms: null,
396
+ expectations: [],
397
+ };
398
+ }
372
399
  const stateHome = fs.mkdtempSync(path.join(os.tmpdir(), `harness-paired-state-${arm}-`));
373
400
  const transcriptFile = path.join(pairDir, `${arm}.transcript.jsonl`);
374
401
  const stderrFile = path.join(pairDir, `${arm}.stderr.txt`);
@@ -388,6 +415,7 @@ function runArm(c, context, arm, pairDir) {
388
415
  fs.rmSync(stateHome, { recursive: true, force: true });
389
416
  return {
390
417
  arm,
418
+ fixture_setup: prepared.fixture_setup,
391
419
  intervention: prepared.intervention,
392
420
  loaded_skills: prepared.loadedSkills,
393
421
  plugin_sha256: null,
@@ -404,7 +432,7 @@ function runArm(c, context, arm, pairDir) {
404
432
  }
405
433
  const invocation = buildInvocation(context.engine, context.model, armPrompt, prepared.ws, c.max_turns);
406
434
  const started = Date.now();
407
- const result = spawnSync(invocation.command, invocation.args, {
435
+ const result = execute(invocation.command, invocation.args, {
408
436
  cwd: prepared.ws,
409
437
  env: { ...process.env, HARNESS_STATE_HOME: stateHome },
410
438
  encoding: 'utf8',
@@ -420,6 +448,7 @@ function runArm(c, context, arm, pairDir) {
420
448
 
421
449
  const base = {
422
450
  arm,
451
+ fixture_setup: prepared.fixture_setup,
423
452
  intervention: prepared.intervention,
424
453
  loaded_skills: prepared.loadedSkills,
425
454
  plugin_sha256: prepared.plugin ? prepared.plugin.sha256 : null,
@@ -566,6 +595,7 @@ function pairContract(c, context) {
566
595
  max_turns: c.max_turns,
567
596
  loaded_skills: treatmentSkills(c).slice().sort(),
568
597
  fixture_sha256: sha256(stableJson(c.fixture)),
598
+ fixture_setup_sha256: fixtureSetupSha256(c),
569
599
  prompt_sha256: sha256(c.prompt),
570
600
  rubric_sha256: sha256(stableJson(c.expectations)),
571
601
  lesson_fixture_sha256: context.effect_type === 'lesson-retrieval' ? sha256(stableJson(c.lesson)) : null,
@@ -781,6 +811,8 @@ function summarizePairs(records, minEffectPp) {
781
811
  requested_pairs: records.length,
782
812
  included_pairs: included.length,
783
813
  excluded_pairs: excluded.length,
814
+ fixture_failures: records.filter(record => Object.values(record.arms || {})
815
+ .some(arm => arm.outcome === 'fixture-error')).length,
784
816
  exclusions: excluded,
785
817
  arm_outcomes: {
786
818
  baseline: { pass: baselinePass, fail: included.length - baselinePass },
@@ -941,7 +973,8 @@ function main(argv = process.argv.slice(2)) {
941
973
  min_effect_pp: Number(flag(argv, '--min-effect-pp')),
942
974
  preflight: flag(argv, '--opencode-preflight'),
943
975
  };
944
- runExperiment(options);
976
+ const { summary } = runExperiment(options);
977
+ if (summary.fixture_failures) process.exitCode = 1;
945
978
  return;
946
979
  }
947
980
  if (command === 'summarize') {
@@ -978,6 +1011,7 @@ module.exports = {
978
1011
  lessonContextFromRetrieval,
979
1012
  lessonFixtureRecord,
980
1013
  prepareArmWorkspace,
1014
+ runArm,
981
1015
  retrieveLesson,
982
1016
  summarizePairs,
983
1017
  validateLessonCase,
@@ -9,7 +9,7 @@
9
9
  const fs = require('fs');
10
10
  const path = require('path');
11
11
  const { execFileSync } = require('child_process');
12
- const { buildWorkspace, validate: validateCases } = require('./run');
12
+ const { prepareWorkspace, validate: validateCases } = require('./run');
13
13
 
14
14
  const ROOT = path.resolve(__dirname, '..');
15
15
  const CASES_DIR = path.join(__dirname, 'cases');
@@ -243,6 +243,36 @@ function grade(c, ws, transcriptPath) {
243
243
  return { results, passed: gating.length > 0 && gating.every((g) => g.pass) };
244
244
  }
245
245
 
246
+ function runCase(c, execute = runHeadless) {
247
+ const prepared = prepareWorkspace(c);
248
+ const ws = prepared.workspace;
249
+ const record = {
250
+ id: c.id,
251
+ engine: 'opencode-with-plugin',
252
+ model: process.env.BEHAVIORAL_MODEL || 'openai/gpt-5-mini',
253
+ pressure: !!c.pressure,
254
+ date: new Date().toISOString(),
255
+ workspace: ws,
256
+ fixture_setup: prepared.setup,
257
+ expectations: [],
258
+ };
259
+ if (!prepared.ok) return { ...record, outcome: 'fixture-error', error: prepared.error };
260
+ try {
261
+ installHarnessWithPlugin(ws);
262
+ if (c.fixture.git) gitSnapshot(ws);
263
+ const transcriptPath = execute(c.prompt, ws, c.max_turns);
264
+ const { results, passed } = grade(c, ws, transcriptPath);
265
+ return {
266
+ ...record,
267
+ transcript: transcriptPath,
268
+ expectations: results.map(({ description, pass, informational }) => ({ description, pass, ...(informational ? { informational: true } : {}) })),
269
+ outcome: passed ? 'pass' : 'fail',
270
+ };
271
+ } catch (error) {
272
+ return { ...record, outcome: 'session-error', error: error.message.slice(0, 500) };
273
+ }
274
+ }
275
+
246
276
  function runLive(filter) {
247
277
  console.log('Engine: opencode (with plugin hooks)');
248
278
  const cases = discoverCases().filter((c) => !filter || c.id === filter);
@@ -252,49 +282,33 @@ function runLive(filter) {
252
282
  let failed = 0;
253
283
  for (const c of cases) {
254
284
  console.log(`\n=== ${c.id}${c.pressure ? ' [PRESSURE]' : ''} ===`);
255
- const ws = buildWorkspace(c);
256
- installHarnessWithPlugin(ws);
257
- if (c.fixture.git) gitSnapshot(ws);
258
- let transcriptPath;
259
- try {
260
- transcriptPath = runHeadless(c.prompt, ws, c.max_turns);
261
- } catch (err) {
262
- console.error(`❌ session failed: ${err.message.slice(0, 300)}`);
263
- failed++;
264
- continue;
265
- }
266
- const { results: graded, passed } = grade(c, ws, transcriptPath);
267
- const record = {
268
- id: c.id,
269
- engine: 'opencode-with-plugin',
270
- model: process.env.BEHAVIORAL_MODEL || 'openai/gpt-5-mini',
271
- pressure: !!c.pressure,
272
- date: new Date().toISOString(),
273
- workspace: ws,
274
- transcript: transcriptPath,
275
- expectations: graded.map(({ description, pass }) => ({ description, pass })),
276
- outcome: passed ? 'pass' : 'fail',
277
- };
285
+ const record = runCase(c);
278
286
  const outFile = path.join(RESULTS_DIR, `plugin-${record.date.slice(0, 10)}-${c.id}.json`);
279
287
  fs.writeFileSync(outFile, JSON.stringify(record, null, 2));
280
- for (const g of graded) console.log(` ${g.pass ? '✅' : '❌'}${g.informational ? ' (info)' : ''} ${g.description}`);
281
- console.log(`${passed ? '✅ PASS' : '❌ FAIL'} -> ${outFile}`);
282
- if (!passed) failed++;
288
+ for (const g of record.expectations) console.log(` ${g.pass ? '✅' : '❌'}${g.informational ? ' (info)' : ''} ${g.description}`);
289
+ console.log(`${record.outcome} -> ${outFile}`);
290
+ if (record.error) console.error(record.error);
291
+ if (record.outcome !== 'pass') failed++;
283
292
  }
284
293
  console.log(failed ? `\n❌ ${failed} case(s) failed.` : '\n🎉 all cases passed.');
285
294
  process.exit(failed ? 1 : 0);
286
295
  }
287
296
 
288
- const args = process.argv.slice(2);
289
- function flag(name) {
290
- const i = args.indexOf(name);
291
- return i >= 0 ? args[i + 1] : undefined;
292
- }
293
- if (args[0] === 'validate') validateCases(discoverCases());
294
- else if (args[0] === 'run') {
295
- const filter = args.includes('--case') ? flag('--case') : undefined;
296
- runLive(filter);
297
- } else {
298
- console.log('Usage:\n node behavioral-evals/run-with-plugin.js run [--case <id>]');
299
- process.exit(args.length ? 1 : 0);
297
+ function main() {
298
+ const args = process.argv.slice(2);
299
+ function flag(name) {
300
+ const i = args.indexOf(name);
301
+ return i >= 0 ? args[i + 1] : undefined;
302
+ }
303
+ if (args[0] === 'validate') validateCases(discoverCases());
304
+ else if (args[0] === 'run') {
305
+ const filter = args.includes('--case') ? flag('--case') : undefined;
306
+ runLive(filter);
307
+ } else {
308
+ console.log('Usage:\n node behavioral-evals/run-with-plugin.js run [--case <id>]');
309
+ process.exit(args.length ? 1 : 0);
310
+ }
300
311
  }
312
+
313
+ if (require.main === module) main();
314
+ module.exports = { runCase };