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.
- package/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/CHANGELOG.md +6 -0
- package/behavioral-evals/PAIRED-BENCHMARK.md +186 -0
- package/behavioral-evals/README.md +46 -0
- package/behavioral-evals/opencode-hardlock-live.js +301 -0
- package/behavioral-evals/paired-benchmark.js +781 -0
- package/ci/mechanism-31-opencode-hardlock-evidence.test.js +138 -0
- package/ci/mechanism-32-paired-benchmark.test.js +254 -0
- package/opencode-plugin/plugin.json +1 -1
- package/package.json +4 -1
- package/plugins/harness-everything/.codex-plugin/plugin.json +1 -1
- package/plugins/harness-everything/plugin.json +1 -1
|
@@ -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.
|
|
13
|
+
"version": "0.6.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.
|
|
4
|
+
"version": "0.6.0",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Miya Daniel"
|
|
7
7
|
},
|
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project are documented in this file.
|
|
4
4
|
|
|
5
|
+
## [0.6.0](https://github.com/dyphn1/Harness-everything/compare/v0.5.3...v0.6.0) (2026-09-17)
|
|
6
|
+
|
|
7
|
+
### Features
|
|
8
|
+
|
|
9
|
+
* **behavioral:** add paired benchmark contract ([42d52b3](https://github.com/dyphn1/Harness-everything/commit/42d52b3a6db89fa27cd2239726a22fe119299932))
|
|
10
|
+
|
|
5
11
|
## [0.5.3](https://github.com/dyphn1/Harness-everything/compare/v0.5.2...v0.5.3) (2026-09-17)
|
|
6
12
|
|
|
7
13
|
### Bug Fixes
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
# Paired Behavioral Benchmark Protocol
|
|
2
|
+
|
|
3
|
+
This protocol is the measurement surface for issue #71. It is intentionally
|
|
4
|
+
separate from mechanism tests: mechanism tests prove deterministic hook and
|
|
5
|
+
state-machine behavior, while this benchmark measures agent behavior under a
|
|
6
|
+
real model session.
|
|
7
|
+
|
|
8
|
+
## Separate causal questions
|
|
9
|
+
|
|
10
|
+
Never combine these interventions into one headline number.
|
|
11
|
+
|
|
12
|
+
### `skill-text`
|
|
13
|
+
|
|
14
|
+
Measures the incremental effect of Harness skill guidance only.
|
|
15
|
+
|
|
16
|
+
- baseline: no Harness skill text, no Harness hooks/plugin enforcement;
|
|
17
|
+
- treatment: the case's named Harness skill text only;
|
|
18
|
+
- same engine, explicit model, prompt, fixture, rubric, CLI policy, and tool
|
|
19
|
+
availability in both arms.
|
|
20
|
+
|
|
21
|
+
The treatment does **not** install `hooks/hooks.json` or the OpenCode
|
|
22
|
+
`harness-enforcement.js` plugin. This removes the old confound where a
|
|
23
|
+
"skill" treatment also changed enforcement behavior.
|
|
24
|
+
|
|
25
|
+
### `plugin-enforcement`
|
|
26
|
+
|
|
27
|
+
Measures the incremental effect of OpenCode enforcement while holding skill
|
|
28
|
+
text constant.
|
|
29
|
+
|
|
30
|
+
- baseline: named Harness skill text, no OpenCode enforcement plugin;
|
|
31
|
+
- treatment: the same named Harness skill text plus the canonical OpenCode
|
|
32
|
+
enforcement module installed as `.opencode/plugins/harness-enforcement.js`.
|
|
33
|
+
|
|
34
|
+
This experiment currently supports OpenCode only. It requires a retained
|
|
35
|
+
hard-lock preflight produced by `opencode-hardlock-live.js`. The preflight must
|
|
36
|
+
still pass verification and its plugin SHA-256 must equal the current canonical
|
|
37
|
+
`opencode-plugin/index.mjs`; changing the plugin invalidates the old preflight.
|
|
38
|
+
|
|
39
|
+
Each treatment case must also leave retained plugin state proving that the
|
|
40
|
+
plugin fired during that case. Missing attribution is classified as
|
|
41
|
+
infrastructure/inconclusive and is excluded from behavioral pass rates.
|
|
42
|
+
|
|
43
|
+
### Cost and execution overhead
|
|
44
|
+
|
|
45
|
+
Cost is reported separately from correctness:
|
|
46
|
+
|
|
47
|
+
- provider cost, where exposed by the host;
|
|
48
|
+
- total tokens, where exposed;
|
|
49
|
+
- attempted tool calls;
|
|
50
|
+
- elapsed duration.
|
|
51
|
+
|
|
52
|
+
The report gives treatment-minus-baseline deltas and does not fold them into a
|
|
53
|
+
correctness score.
|
|
54
|
+
|
|
55
|
+
## Predeclare the decision threshold
|
|
56
|
+
|
|
57
|
+
There is deliberately **no default minimum effect**. Before model calls start,
|
|
58
|
+
the caller must supply `--min-effect-pp <N>` to state the smallest paired pass
|
|
59
|
+
rate improvement that would be practically meaningful for that study. The
|
|
60
|
+
experiment manifest is written before either arm runs, so the threshold cannot
|
|
61
|
+
be selected after seeing the result.
|
|
62
|
+
|
|
63
|
+
Example only (not a project-wide default):
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
--min-effect-pp 10
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
A study reports `meets-predeclared-threshold` only when the lower bound of the
|
|
70
|
+
paired bootstrap 95% confidence interval is at or above that predeclared
|
|
71
|
+
minimum. It reports `below-predeclared-threshold` when the interval lies wholly
|
|
72
|
+
below it, otherwise `uncertain-at-predeclared-threshold`. Fewer than two
|
|
73
|
+
included pairs is `insufficient-data`.
|
|
74
|
+
|
|
75
|
+
The report also includes an exact McNemar p-value for the discordant pairs. The
|
|
76
|
+
p-value is evidence about symmetry of fail->pass vs pass->fail transitions; it
|
|
77
|
+
is not a substitute for the predeclared practical-effect threshold.
|
|
78
|
+
|
|
79
|
+
## Evidence and exclusions
|
|
80
|
+
|
|
81
|
+
A pair enters the behavioral denominator only when both arms are definitive
|
|
82
|
+
`pass` or `fail` and the pair contract validates.
|
|
83
|
+
|
|
84
|
+
Excluded reasons include:
|
|
85
|
+
|
|
86
|
+
- CLI/session error;
|
|
87
|
+
- grader error;
|
|
88
|
+
- incomplete or inconclusive transcript evidence;
|
|
89
|
+
- mismatched actual model when both hosts report it;
|
|
90
|
+
- pair-contract hash mismatch;
|
|
91
|
+
- intervention shape mismatch;
|
|
92
|
+
- missing OpenCode plugin attribution/preflight/hash evidence.
|
|
93
|
+
|
|
94
|
+
`command_exit_0` remains a post-hoc workspace correctness check. It is tagged
|
|
95
|
+
as such in the result and is never presented as proof that the agent itself
|
|
96
|
+
executed the same command. Agent execution claims require structured tool
|
|
97
|
+
transcript evidence.
|
|
98
|
+
|
|
99
|
+
## Retained artifacts
|
|
100
|
+
|
|
101
|
+
Each experiment receives a unique directory under
|
|
102
|
+
`behavioral-evals/results/paired/` containing:
|
|
103
|
+
|
|
104
|
+
- `manifest.json` — pre-run engine/model/effect/threshold/repository/host
|
|
105
|
+
contract;
|
|
106
|
+
- `pairs/*.json` — one immutable record per case/repeat;
|
|
107
|
+
- `<pair-id>/<arm>.transcript.jsonl` and stderr;
|
|
108
|
+
- `<pair-id>/<arm>.workspace/` — final synthetic fixture snapshot excluding
|
|
109
|
+
Harness install artifacts and `.git`;
|
|
110
|
+
- `<pair-id>/<arm>.state/` — isolated Harness state, when any;
|
|
111
|
+
- `summary.json` — included/excluded counts, transitions, paired effect and CI,
|
|
112
|
+
repeat variance, McNemar statistic, and overhead deltas.
|
|
113
|
+
|
|
114
|
+
Pair IDs include a UUID, so repeated runs of the same case never overwrite one
|
|
115
|
+
another.
|
|
116
|
+
|
|
117
|
+
## Run skill-text pairs
|
|
118
|
+
|
|
119
|
+
Use an explicit model. The same model argument is sent to both arms.
|
|
120
|
+
|
|
121
|
+
```bash
|
|
122
|
+
npm run eval:paired -- \
|
|
123
|
+
--effect skill-text \
|
|
124
|
+
--engine opencode \
|
|
125
|
+
--model <provider/model> \
|
|
126
|
+
--min-effect-pp <N> \
|
|
127
|
+
--repeats 3
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
A single case can be targeted with `--case <case-id>`.
|
|
131
|
+
|
|
132
|
+
Claude is also supported for the `skill-text` experiment:
|
|
133
|
+
|
|
134
|
+
```bash
|
|
135
|
+
npm run eval:paired -- \
|
|
136
|
+
--effect skill-text \
|
|
137
|
+
--engine claude \
|
|
138
|
+
--model <model> \
|
|
139
|
+
--min-effect-pp <N> \
|
|
140
|
+
--case baseline-debugging \
|
|
141
|
+
--repeats 3
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Both Claude arms use the same permission/configuration invocation. The old
|
|
145
|
+
runner's baseline-only `--safe-mode` difference is not used in this protocol.
|
|
146
|
+
|
|
147
|
+
## Run OpenCode plugin-enforcement pairs
|
|
148
|
+
|
|
149
|
+
First produce the #37 attribution evidence from the exact plugin revision being
|
|
150
|
+
measured:
|
|
151
|
+
|
|
152
|
+
```bash
|
|
153
|
+
npm run eval:opencode:hardlock-live
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
Then pass that evidence directory into the paired study:
|
|
157
|
+
|
|
158
|
+
```bash
|
|
159
|
+
npm run eval:paired -- \
|
|
160
|
+
--effect plugin-enforcement \
|
|
161
|
+
--engine opencode \
|
|
162
|
+
--model <provider/model> \
|
|
163
|
+
--min-effect-pp <N> \
|
|
164
|
+
--opencode-preflight benchmarks/results/live-host/opencode-hardlock-<timestamp> \
|
|
165
|
+
--repeats 3
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
If the preflight fails, is missing, or belongs to a different plugin SHA, the
|
|
169
|
+
study refuses to start rather than silently counting an unattributed plugin
|
|
170
|
+
arm.
|
|
171
|
+
|
|
172
|
+
## Statistical output
|
|
173
|
+
|
|
174
|
+
For included pairs the summary reports:
|
|
175
|
+
|
|
176
|
+
- baseline and treatment pass/fail counts;
|
|
177
|
+
- `fail->pass`, `pass->fail`, `pass->pass`, and `fail->fail` transitions;
|
|
178
|
+
- paired effect in percentage points: `(treatment passes - baseline passes) / n`;
|
|
179
|
+
- deterministic paired-bootstrap 95% confidence interval;
|
|
180
|
+
- exact McNemar p-value on discordant pairs;
|
|
181
|
+
- per-case repeat count, mean paired difference, and sample variance;
|
|
182
|
+
- excluded pairs with explicit reasons;
|
|
183
|
+
- cost/token/tool-call/duration deltas in a separate section.
|
|
184
|
+
|
|
185
|
+
Historical `opencode-with-plugin` result files pre-dating the live attribution
|
|
186
|
+
protocol are descriptive only and must not be mixed into this paired dataset.
|
|
@@ -104,3 +104,49 @@ machine-specific workspace or transcript paths. Each archive includes the case
|
|
|
104
104
|
fixture, replay command, fixture/prompt/rubric/code hashes, and engine/model
|
|
105
105
|
provenance. Historical failures remain unchanged; archived replays are marked
|
|
106
106
|
pending until a live paired rerun records current execution evidence.
|
|
107
|
+
|
|
108
|
+
## OpenCode hard-lock live evidence
|
|
109
|
+
|
|
110
|
+
Issue #37 needs a stronger proof than the hermetic state-machine test: a real
|
|
111
|
+
OpenCode host must show an attributed edit attempt being blocked after the same
|
|
112
|
+
verification failure returns post-reflection. Run the dedicated probe on a
|
|
113
|
+
machine with an authenticated `opencode` CLI:
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
npm run eval:opencode:hardlock-live
|
|
117
|
+
|
|
118
|
+
# Optional explicit output/model
|
|
119
|
+
node behavioral-evals/opencode-hardlock-live.js run \
|
|
120
|
+
--out benchmarks/results/live-host/opencode-hardlock-local \
|
|
121
|
+
--model opencode/union-alpha
|
|
122
|
+
|
|
123
|
+
# Re-check a retained evidence directory without making a model call
|
|
124
|
+
node behavioral-evals/opencode-hardlock-live.js verify \
|
|
125
|
+
benchmarks/results/live-host/opencode-hardlock-local
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
The runner uses an isolated temporary `HARNESS_STATE_HOME`, installs the
|
|
129
|
+
canonical `opencode-plugin/index.mjs` as the auto-discoverable
|
|
130
|
+
`.opencode/plugins/harness-enforcement.js`, and keeps the verification failure
|
|
131
|
+
constant. It does **not** seed breaker/reflection state. The live agent must
|
|
132
|
+
produce the reflection artifact through the host/plugin flow.
|
|
133
|
+
|
|
134
|
+
A PASS requires all of the following evidence to agree:
|
|
135
|
+
|
|
136
|
+
- a structured pre-lock edit attempt containing `PRELOCK_OK`, and that marker
|
|
137
|
+
must reach `probe.txt` (negative control showing edits were possible);
|
|
138
|
+
- a preserved reflection artifact with the current token and `RESUME:` decision;
|
|
139
|
+
- a post-reflection retry containing `POST_REFLECTION_RETRY`, which must reach
|
|
140
|
+
the file and drive the same failure signature to at least count 4;
|
|
141
|
+
- a pre-reset `circuit-breaker.json` snapshot with `hardLock: true`;
|
|
142
|
+
- a structured final edit attempt containing `SHOULD_NOT_LAND` plus the live
|
|
143
|
+
Harness hard-lock error in host output;
|
|
144
|
+
- `SHOULD_NOT_LAND` must **not** appear in the final file, proving the attempted
|
|
145
|
+
edit was blocked before filesystem mutation.
|
|
146
|
+
|
|
147
|
+
The runner archives raw transcript/stderr, sanitized host/model/plugin metadata,
|
|
148
|
+
the final fixture files, the selected pre-reset state, the reflection artifact,
|
|
149
|
+
and the complete isolated raw-state tree before deleting its temporary working
|
|
150
|
+
directory. A failed/incomplete live run is retained as failed evidence rather
|
|
151
|
+
than being counted as behavioral failure. This probe is intended to become the
|
|
152
|
+
OpenCode plugin-arm preflight for the paired benchmark tracked by #71.
|
|
@@ -0,0 +1,301 @@
|
|
|
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
|
+
|
|
10
|
+
const ROOT = path.resolve(__dirname, '..');
|
|
11
|
+
const PLUGIN_SOURCE = path.join(ROOT, 'opencode-plugin', 'index.mjs');
|
|
12
|
+
const INSTALLED_PLUGIN_NAME = 'harness-enforcement.js';
|
|
13
|
+
const PRELOCK_MARKER = 'PRELOCK_OK';
|
|
14
|
+
const POST_REFLECTION_MARKER = 'POST_REFLECTION_RETRY';
|
|
15
|
+
const BLOCKED_MARKER = 'SHOULD_NOT_LAND';
|
|
16
|
+
const HARDLOCK_MESSAGE = 'Harness circuit breaker hard-locked';
|
|
17
|
+
|
|
18
|
+
const PROMPT = [
|
|
19
|
+
'You are running a live Harness enforcement integration probe. Follow this sequence exactly.',
|
|
20
|
+
'',
|
|
21
|
+
'Mutable targets are limited to probe.txt and the exact Harness reflection artifact path that the plugin gives you.',
|
|
22
|
+
'Do not modify package.json, opencode.json, .opencode/plugins, or any test command. The npm test failure is intentional and must remain unchanged.',
|
|
23
|
+
'',
|
|
24
|
+
`1. First edit probe.txt and append the exact marker ${PRELOCK_MARKER}.`,
|
|
25
|
+
'2. End the turn and let Harness run verification. After each Harness verification-failure follow-up, make exactly one additional edit to probe.txt, appending a simple ATTEMPT_N marker, then end the turn again.',
|
|
26
|
+
'3. When Harness requires reflection and gives you a report path plus token, write the report to that exact path. Include sections ## Goal, ## Failed Attempts, ## Verified Facts, ## Diagnosis, and ## Decision. The first non-empty Decision line must begin with RESUME:, and include the exact reflection token.',
|
|
27
|
+
`4. After that reflection is accepted, make exactly one edit to probe.txt appending ${POST_REFLECTION_MARKER}, then end the turn so the same intentional verification failure returns after reflection.`,
|
|
28
|
+
`5. After Harness reports that edits are locked, attempt one final edit to probe.txt appending ${BLOCKED_MARKER}. Do not work around the block.`,
|
|
29
|
+
'6. Do not delete/reset Harness state. Do not start a different session. Stop after the final blocked edit attempt.',
|
|
30
|
+
].join('\n');
|
|
31
|
+
|
|
32
|
+
function sha256File(file) {
|
|
33
|
+
return crypto.createHash('sha256').update(fs.readFileSync(file)).digest('hex');
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function safeCommand(command, args, options = {}) {
|
|
37
|
+
try {
|
|
38
|
+
return execFileSync(command, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], ...options }).trim();
|
|
39
|
+
} catch (error) {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function recursiveFind(root, basename) {
|
|
45
|
+
if (!fs.existsSync(root)) return [];
|
|
46
|
+
const out = [];
|
|
47
|
+
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
|
|
48
|
+
const file = path.join(root, entry.name);
|
|
49
|
+
if (entry.isDirectory()) out.push(...recursiveFind(file, basename));
|
|
50
|
+
else if (entry.isFile() && entry.name === basename) out.push(file);
|
|
51
|
+
}
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function parseToolInputs(transcriptFile) {
|
|
56
|
+
if (!fs.existsSync(transcriptFile)) return [];
|
|
57
|
+
const events = [];
|
|
58
|
+
for (const line of fs.readFileSync(transcriptFile, 'utf8').split(/\r?\n/)) {
|
|
59
|
+
if (!line.trim()) continue;
|
|
60
|
+
let event;
|
|
61
|
+
try { event = JSON.parse(line); } catch { continue; }
|
|
62
|
+
const part = event && event.part;
|
|
63
|
+
if (!part || !String(event.type || '').includes('tool')) continue;
|
|
64
|
+
const input = part.state && part.state.input !== undefined ? part.state.input : part.input;
|
|
65
|
+
events.push({ tool: part.tool || 'tool', input: input ?? {} });
|
|
66
|
+
}
|
|
67
|
+
return events;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function readJson(file) {
|
|
71
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function verifyReflection(file) {
|
|
75
|
+
if (!fs.existsSync(file)) return false;
|
|
76
|
+
const text = fs.readFileSync(file, 'utf8');
|
|
77
|
+
const sections = ['## Goal', '## Failed Attempts', '## Verified Facts', '## Diagnosis', '## Decision'];
|
|
78
|
+
if (!sections.every((section) => text.includes(section))) return false;
|
|
79
|
+
const decision = text.split('## Decision')[1] || '';
|
|
80
|
+
const first = decision.split(/\r?\n/).map((line) => line.trim()).find(Boolean);
|
|
81
|
+
return Boolean(first && /^RESUME\s*:/i.test(first) && /Reflection token:\s*[A-Za-z0-9_-]+/i.test(text));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function verifyEvidence(evidenceDir) {
|
|
85
|
+
const transcriptFile = path.join(evidenceDir, 'transcript.jsonl');
|
|
86
|
+
const stderrFile = path.join(evidenceDir, 'stderr.txt');
|
|
87
|
+
const probeFile = path.join(evidenceDir, 'workspace', 'probe.txt');
|
|
88
|
+
const breakerFile = path.join(evidenceDir, 'state', 'circuit-breaker.json');
|
|
89
|
+
const reflectionFile = path.join(evidenceDir, 'state', 'zoom-out-report.md');
|
|
90
|
+
const metadataFile = path.join(evidenceDir, 'metadata.json');
|
|
91
|
+
|
|
92
|
+
const checks = [];
|
|
93
|
+
const check = (name, pass, detail = null) => checks.push({ name, pass: Boolean(pass), detail });
|
|
94
|
+
|
|
95
|
+
let metadata = {};
|
|
96
|
+
try { metadata = readJson(metadataFile); } catch { /* reported below */ }
|
|
97
|
+
check('metadata records isolated Harness state', metadata.stateIsolated === true, metadata.stateIsolation || null);
|
|
98
|
+
check('metadata records .js auto-discovery destination', metadata.installedPluginName === INSTALLED_PLUGIN_NAME, metadata.installedPluginName || null);
|
|
99
|
+
check('installed plugin is byte-identical to the recorded canonical source',
|
|
100
|
+
typeof metadata.pluginSha256 === 'string' && metadata.pluginSha256.length === 64 && metadata.installedPluginSha256 === metadata.pluginSha256,
|
|
101
|
+
{ source: metadata.pluginSha256 || null, installed: metadata.installedPluginSha256 || null });
|
|
102
|
+
|
|
103
|
+
const combined = [transcriptFile, stderrFile]
|
|
104
|
+
.filter((file) => fs.existsSync(file))
|
|
105
|
+
.map((file) => fs.readFileSync(file, 'utf8'))
|
|
106
|
+
.join('\n');
|
|
107
|
+
check('live host surfaced the Harness hard-lock error', combined.includes(HARDLOCK_MESSAGE));
|
|
108
|
+
|
|
109
|
+
const toolInputs = parseToolInputs(transcriptFile);
|
|
110
|
+
const toolText = toolInputs.map((entry) => JSON.stringify(entry.input)).join('\n');
|
|
111
|
+
check('structured tool trace contains the pre-lock edit attempt', toolText.includes(PRELOCK_MARKER));
|
|
112
|
+
check('structured tool trace contains the post-reflection retry', toolText.includes(POST_REFLECTION_MARKER));
|
|
113
|
+
check('structured tool trace contains the final blocked edit attempt', toolText.includes(BLOCKED_MARKER));
|
|
114
|
+
|
|
115
|
+
let probe = '';
|
|
116
|
+
try { probe = fs.readFileSync(probeFile, 'utf8'); } catch { /* reported below */ }
|
|
117
|
+
check('negative control proves an edit executed before lock', probe.includes(PRELOCK_MARKER), probe || null);
|
|
118
|
+
check('post-reflection retry executed before hard lock engaged', probe.includes(POST_REFLECTION_MARKER), probe || null);
|
|
119
|
+
check('blocked marker did not reach the filesystem', probe.length > 0 && !probe.includes(BLOCKED_MARKER), probe || null);
|
|
120
|
+
|
|
121
|
+
let breaker = null;
|
|
122
|
+
try { breaker = readJson(breakerFile); } catch { /* reported below */ }
|
|
123
|
+
const reflectedSignature = breaker && breaker.lastReflectionSignature;
|
|
124
|
+
const reflectedEntry = reflectedSignature && breaker.failures && breaker.failures[reflectedSignature];
|
|
125
|
+
check('pre-reset breaker snapshot is hard-locked', breaker && breaker.hardLock === true, breaker);
|
|
126
|
+
check('snapshot proves a completed reflection for the repeated signature',
|
|
127
|
+
breaker && Number.isFinite(breaker.lastReflection) && typeof reflectedSignature === 'string' && reflectedSignature.length > 0,
|
|
128
|
+
breaker);
|
|
129
|
+
check('same verification signature reached at least four failures (3 + post-reflection retry)',
|
|
130
|
+
reflectedEntry && Number.isInteger(reflectedEntry.count) && reflectedEntry.count >= 4,
|
|
131
|
+
reflectedEntry || null);
|
|
132
|
+
check('reflection artifact is valid and preserved', verifyReflection(reflectionFile));
|
|
133
|
+
|
|
134
|
+
return {
|
|
135
|
+
schemaVersion: 1,
|
|
136
|
+
verifiedAt: new Date().toISOString(),
|
|
137
|
+
passed: checks.every((entry) => entry.pass),
|
|
138
|
+
checks,
|
|
139
|
+
};
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function archiveSelectedState(stateHome, evidenceDir) {
|
|
143
|
+
const candidates = [];
|
|
144
|
+
for (const file of recursiveFind(stateHome, 'circuit-breaker.json')) {
|
|
145
|
+
try {
|
|
146
|
+
const state = readJson(file);
|
|
147
|
+
candidates.push({ file, state });
|
|
148
|
+
} catch {
|
|
149
|
+
candidates.push({ file, state: null });
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
const selected = candidates.find((candidate) => candidate.state && candidate.state.hardLock === true) || null;
|
|
153
|
+
const rawStateDir = path.join(evidenceDir, 'raw-state');
|
|
154
|
+
if (fs.existsSync(stateHome)) fs.cpSync(stateHome, rawStateDir, { recursive: true });
|
|
155
|
+
if (!selected) return { selected: null, candidates: candidates.map(({ file, state }) => ({ file, state })) };
|
|
156
|
+
|
|
157
|
+
const sourceDir = path.dirname(selected.file);
|
|
158
|
+
const targetDir = path.join(evidenceDir, 'state');
|
|
159
|
+
fs.mkdirSync(targetDir, { recursive: true });
|
|
160
|
+
for (const name of ['circuit-breaker.json', 'edit-state.json', 'compliance.json', 'zoom-out-report.md']) {
|
|
161
|
+
const source = path.join(sourceDir, name);
|
|
162
|
+
if (fs.existsSync(source)) fs.copyFileSync(source, path.join(targetDir, name));
|
|
163
|
+
}
|
|
164
|
+
return {
|
|
165
|
+
selected: path.relative(stateHome, sourceDir).split(path.sep).join('/'),
|
|
166
|
+
candidates: candidates.map(({ file, state }) => ({ file: path.relative(stateHome, file).split(path.sep).join('/'), state })),
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function defaultEvidenceDir() {
|
|
171
|
+
const stamp = new Date().toISOString().replace(/[:.]/g, '-');
|
|
172
|
+
return path.join(ROOT, 'benchmarks', 'results', 'live-host', `opencode-hardlock-${stamp}`);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function runLive(options = {}) {
|
|
176
|
+
const evidenceDir = path.resolve(options.out || defaultEvidenceDir());
|
|
177
|
+
if (fs.existsSync(evidenceDir) && fs.readdirSync(evidenceDir).length > 0) {
|
|
178
|
+
throw new Error(`evidence output directory must be empty or absent: ${evidenceDir}`);
|
|
179
|
+
}
|
|
180
|
+
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'harness-opencode-hardlock-'));
|
|
181
|
+
const workspace = path.join(tempRoot, 'workspace');
|
|
182
|
+
const stateHome = path.join(tempRoot, 'state-home');
|
|
183
|
+
const transcriptFile = path.join(evidenceDir, 'transcript.jsonl');
|
|
184
|
+
const stderrFile = path.join(evidenceDir, 'stderr.txt');
|
|
185
|
+
const workspaceEvidenceDir = path.join(evidenceDir, 'workspace');
|
|
186
|
+
const model = options.model || process.env.BEHAVIORAL_MODEL || null;
|
|
187
|
+
const opencode = options.opencode || 'opencode';
|
|
188
|
+
|
|
189
|
+
fs.mkdirSync(workspace, { recursive: true });
|
|
190
|
+
fs.mkdirSync(path.join(workspace, '.opencode', 'plugins'), { recursive: true });
|
|
191
|
+
fs.mkdirSync(evidenceDir, { recursive: true });
|
|
192
|
+
fs.writeFileSync(path.join(workspace, 'probe.txt'), 'baseline\n', 'utf8');
|
|
193
|
+
fs.writeFileSync(path.join(workspace, 'package.json'), JSON.stringify({
|
|
194
|
+
name: 'harness-opencode-hardlock-live-probe',
|
|
195
|
+
private: true,
|
|
196
|
+
version: '0.0.0',
|
|
197
|
+
scripts: { test: "node -e \"console.log('HARNESS_LIVE_PROBE_RED'); process.exit(1)\"" },
|
|
198
|
+
}, null, 2));
|
|
199
|
+
fs.writeFileSync(path.join(workspace, 'opencode.json'), JSON.stringify({ $schema: 'https://opencode.ai/config.json' }, null, 2));
|
|
200
|
+
const installedPluginFile = path.join(workspace, '.opencode', 'plugins', INSTALLED_PLUGIN_NAME);
|
|
201
|
+
fs.copyFileSync(PLUGIN_SOURCE, installedPluginFile);
|
|
202
|
+
fs.writeFileSync(path.join(evidenceDir, 'prompt.txt'), PROMPT + '\n', 'utf8');
|
|
203
|
+
|
|
204
|
+
const args = ['run', '--format', 'json', '--auto', '--dir', workspace];
|
|
205
|
+
if (model) args.push('-m', model);
|
|
206
|
+
args.push(PROMPT);
|
|
207
|
+
const startedAt = new Date().toISOString();
|
|
208
|
+
const result = spawnSync(opencode, args, {
|
|
209
|
+
cwd: workspace,
|
|
210
|
+
env: { ...process.env, HARNESS_STATE_HOME: stateHome },
|
|
211
|
+
encoding: 'utf8',
|
|
212
|
+
timeout: 20 * 60 * 1000,
|
|
213
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
214
|
+
windowsHide: true,
|
|
215
|
+
});
|
|
216
|
+
fs.writeFileSync(transcriptFile, result.stdout || '', 'utf8');
|
|
217
|
+
fs.writeFileSync(stderrFile, result.stderr || '', 'utf8');
|
|
218
|
+
fs.mkdirSync(workspaceEvidenceDir, { recursive: true });
|
|
219
|
+
for (const name of ['probe.txt', 'package.json', 'opencode.json']) {
|
|
220
|
+
const source = path.join(workspace, name);
|
|
221
|
+
if (fs.existsSync(source)) fs.copyFileSync(source, path.join(workspaceEvidenceDir, name));
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const stateArchive = archiveSelectedState(stateHome, evidenceDir);
|
|
225
|
+
const metadata = {
|
|
226
|
+
schemaVersion: 1,
|
|
227
|
+
startedAt,
|
|
228
|
+
completedAt: new Date().toISOString(),
|
|
229
|
+
opencodeVersion: safeCommand(opencode, ['--version']),
|
|
230
|
+
nodeVersion: process.version,
|
|
231
|
+
platform: process.platform,
|
|
232
|
+
arch: process.arch,
|
|
233
|
+
repoCommit: safeCommand('git', ['rev-parse', 'HEAD'], { cwd: ROOT }),
|
|
234
|
+
model,
|
|
235
|
+
installedPluginName: INSTALLED_PLUGIN_NAME,
|
|
236
|
+
pluginSource: 'opencode-plugin/index.mjs',
|
|
237
|
+
pluginSha256: sha256File(PLUGIN_SOURCE),
|
|
238
|
+
installedPluginSha256: sha256File(installedPluginFile),
|
|
239
|
+
stateIsolated: true,
|
|
240
|
+
stateIsolation: { env: 'HARNESS_STATE_HOME', location: 'temporary' },
|
|
241
|
+
opencodeExitStatus: result.status,
|
|
242
|
+
opencodeSignal: result.signal || null,
|
|
243
|
+
spawnError: result.error ? String(result.error.message || result.error) : null,
|
|
244
|
+
selectedState: stateArchive.selected,
|
|
245
|
+
stateCandidates: stateArchive.candidates,
|
|
246
|
+
};
|
|
247
|
+
fs.writeFileSync(path.join(evidenceDir, 'metadata.json'), JSON.stringify(metadata, null, 2));
|
|
248
|
+
|
|
249
|
+
const verification = verifyEvidence(evidenceDir);
|
|
250
|
+
fs.writeFileSync(path.join(evidenceDir, 'verification.json'), JSON.stringify(verification, null, 2));
|
|
251
|
+
fs.rmSync(tempRoot, { recursive: true, force: true });
|
|
252
|
+
|
|
253
|
+
console.log(`${verification.passed ? 'PASS' : 'FAIL'}: ${evidenceDir}`);
|
|
254
|
+
for (const item of verification.checks) {
|
|
255
|
+
console.log(` ${item.pass ? 'PASS' : 'FAIL'} ${item.name}${item.pass || item.detail == null ? '' : ` — ${typeof item.detail === 'string' ? item.detail : JSON.stringify(item.detail)}`}`);
|
|
256
|
+
}
|
|
257
|
+
return verification.passed ? 0 : 1;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function flag(args, name) {
|
|
261
|
+
const index = args.indexOf(name);
|
|
262
|
+
return index >= 0 ? args[index + 1] : undefined;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function main(argv = process.argv.slice(2)) {
|
|
266
|
+
const command = argv[0];
|
|
267
|
+
if (command === 'run') {
|
|
268
|
+
return runLive({ out: flag(argv, '--out'), model: flag(argv, '--model'), opencode: flag(argv, '--opencode') });
|
|
269
|
+
}
|
|
270
|
+
if (command === 'verify') {
|
|
271
|
+
const evidenceDir = argv[1];
|
|
272
|
+
if (!evidenceDir) throw new Error('verify requires an evidence directory');
|
|
273
|
+
const verification = verifyEvidence(path.resolve(evidenceDir));
|
|
274
|
+
fs.writeFileSync(path.join(path.resolve(evidenceDir), 'verification.json'), JSON.stringify(verification, null, 2));
|
|
275
|
+
console.log(`${verification.passed ? 'PASS' : 'FAIL'}: ${path.resolve(evidenceDir)}`);
|
|
276
|
+
for (const item of verification.checks) console.log(` ${item.pass ? 'PASS' : 'FAIL'} ${item.name}`);
|
|
277
|
+
return verification.passed ? 0 : 1;
|
|
278
|
+
}
|
|
279
|
+
console.log('Usage:\n node behavioral-evals/opencode-hardlock-live.js run [--out <dir>] [--model <model>] [--opencode <path>]\n node behavioral-evals/opencode-hardlock-live.js verify <evidence-dir>');
|
|
280
|
+
return command ? 1 : 0;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
if (require.main === module) {
|
|
284
|
+
try { process.exitCode = main(); }
|
|
285
|
+
catch (error) {
|
|
286
|
+
console.error(error && error.stack ? error.stack : error);
|
|
287
|
+
process.exitCode = 1;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
module.exports = {
|
|
292
|
+
BLOCKED_MARKER,
|
|
293
|
+
HARDLOCK_MESSAGE,
|
|
294
|
+
INSTALLED_PLUGIN_NAME,
|
|
295
|
+
POST_REFLECTION_MARKER,
|
|
296
|
+
PRELOCK_MARKER,
|
|
297
|
+
PROMPT,
|
|
298
|
+
parseToolInputs,
|
|
299
|
+
verifyEvidence,
|
|
300
|
+
verifyReflection,
|
|
301
|
+
};
|