harness-everything 0.47.3 → 0.47.4

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.
@@ -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.47.3",
13
+ "version": "0.47.4",
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.47.3",
4
+ "version": "0.47.4",
5
5
  "author": {
6
6
  "name": "Miya Daniel"
7
7
  },
package/CHANGELOG.md CHANGED
@@ -2,6 +2,13 @@
2
2
 
3
3
  All notable changes to this project are documented in this file.
4
4
 
5
+ ## [0.47.4](https://github.com/dyphn1/Harness-everything/compare/v0.47.3...v0.47.4) (2026-11-03)
6
+
7
+ ### Bug Fixes
8
+
9
+ * **behavioral-evals:** grade the whole final assistant message for json-only ([ebbe33c](https://github.com/dyphn1/Harness-everything/commit/ebbe33c7ac8beb3d2b104f81666f06a0ebfcd77d)), closes [#308](https://github.com/dyphn1/Harness-everything/issues/308)
10
+ * **behavioral-evals:** recombine final text parts exactly for json-only grading ([de8341c](https://github.com/dyphn1/Harness-everything/commit/de8341c0621c6aacf319467a9e3c9b1420f51f37)), closes [#308](https://github.com/dyphn1/Harness-everything/issues/308)
11
+
5
12
  ## [0.47.3](https://github.com/dyphn1/Harness-everything/compare/v0.47.2...v0.47.3) (2026-10-31)
6
13
 
7
14
  ### Bug Fixes
@@ -171,6 +171,16 @@ additional arguments) or `tool_denied` when denial is the behavior under test.
171
171
  Legacy one-object Claude JSON and incomplete streams are graded inconclusive
172
172
  for execution evidence.
173
173
 
174
+ Use `final_output_json_only` when the final message must be only a JSON object
175
+ (raw, or inside one fenced block) with no prose outside it. Hidden
176
+ `<!-- harness-label -->` lines are ignored. Optional `required_keys` lists keys
177
+ that must be present. Extracting the selection fields from prose does not pass.
178
+ The final message is every assistant text part after the last tool call, joined
179
+ by exact concatenation (so JSON streamed in chunks reassembles exactly), with
180
+ newline-separated blocks as an alternative; the case passes if either reading is
181
+ a single JSON output. Claude's repeated `result` is used only when no parts exist.
182
+ Prose streamed in a separate part still fails under both readings.
183
+
174
184
  Grading is best-effort mechanical (trace keyword/state assertions), not a
175
185
  substitute for reading the transcript — every result JSON records the full
176
186
  trace path so humans can audit what the grader concluded.
@@ -25,6 +25,7 @@ const EXPECTATION_TYPES = new Set([
25
25
  'tool_completed',
26
26
  'tool_denied',
27
27
  'execution_evidence',
28
+ 'final_output_json_only',
28
29
  ]);
29
30
 
30
31
  const PRESSURE_CATEGORIES = new Set([
@@ -118,6 +119,12 @@ function validateExpectation(expectation, index) {
118
119
  errors.push(`expectations[${index}] ${type} requires a string value`);
119
120
  }
120
121
  if (type === 'command_exit_0' && (typeof expectation.command !== 'string' || !expectation.command.trim())) errors.push(`expectations[${index}] command_exit_0 requires a non-empty command`);
122
+ if (type === 'final_output_json_only' && expectation.required_keys !== undefined) {
123
+ const keys = expectation.required_keys;
124
+ if (!Array.isArray(keys) || keys.some(key => typeof key !== 'string' || !key.trim())) {
125
+ errors.push(`expectations[${index}] required_keys must be an array of non-empty strings`);
126
+ }
127
+ }
121
128
  if (['tool_attempted', 'tool_executed', 'tool_completed', 'tool_denied', 'execution_evidence'].includes(type)) {
122
129
  if (expectation.value === undefined && expectation.tool === undefined && expectation.name === undefined && expectation.command === undefined) {
123
130
  errors.push(`expectations[${index}] ${type} requires a tool/value target`);
@@ -0,0 +1,23 @@
1
+ # Baseline case: Profile lookup returns only the JSON selection record
2
+ id: baseline-profile-lookup-json-only
3
+ name: Profile lookup answers with a JSON-only selection record
4
+ discipline: fable-mode
5
+ loaded_skills:
6
+ - fable-mode
7
+ fixture:
8
+ files:
9
+ - path: README.md
10
+ content: |
11
+ Profile lookup fixture. No orchestration or delegation is needed.
12
+ prompt: "Explain what 'fable on sonnect' means; return only a small JSON selection record with requestedProfile, effectiveProfile, profileAlias, assignedRole, runtimeModel, runtimeEffort. No orchestration or delegation."
13
+ max_turns: 4
14
+ expectations:
15
+ - type: final_output_json_only
16
+ required_keys:
17
+ - requestedProfile
18
+ - effectiveProfile
19
+ - profileAlias
20
+ - assignedRole
21
+ - runtimeModel
22
+ - runtimeEffort
23
+ description: final message is exactly one JSON selection record (raw or fenced) with no prose outside it, and carries all six selection fields; selection-field extraction alone is not completion
@@ -28,7 +28,7 @@ const {
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
+ const { resolveFixtureSetup, validateFixture, validateExpectation } = require('./case-validator');
32
32
 
33
33
  const ROOT = path.resolve(__dirname, '..');
34
34
  const CASES_DIR = path.join(__dirname, 'cases');
@@ -150,6 +150,7 @@ function validate(cases) {
150
150
  const EXPECT_TYPES = new Set([
151
151
  'trace_contains', 'trace_not_contains', 'file_contains', 'file_not_exists', 'command_exit_0',
152
152
  'tool_call', 'tool_attempted', 'tool_completed', 'tool_executed', 'tool_denied', 'execution_evidence',
153
+ 'final_output_json_only',
153
154
  ]);
154
155
  for (const c of cases) {
155
156
  const problems = [];
@@ -165,7 +166,9 @@ function validate(cases) {
165
166
  if (!resolveTreatmentSkills(c).length) problems.push('treatment must load at least one named Harness skill');
166
167
  for (const e of c.expectations || []) {
167
168
  if (!EXPECT_TYPES.has(e.type)) problems.push(`unknown expectation type "${e.type}"`);
168
- if (e.value === undefined && e.command === undefined && e.tool === undefined && e.name === undefined && e.count === undefined) {
169
+ if (e.type === 'final_output_json_only') {
170
+ problems.push(...validateExpectation(e, c.expectations.indexOf(e)));
171
+ } else if (e.value === undefined && e.command === undefined && e.tool === undefined && e.name === undefined && e.count === undefined) {
169
172
  problems.push(`expectation ${e.type} needs value, tool, name, count, or command`);
170
173
  }
171
174
  }
@@ -492,8 +495,10 @@ function parseTranscriptEvents(transcriptPath) {
492
495
  const events = [];
493
496
  const pendingById = new Map();
494
497
  let sequence = 0;
495
- const addText = text => {
496
- if (typeof text === 'string' && text) events.push({ kind: 'text', text, sequence: sequence++ });
498
+ // `source` separates assistant content parts from Claude's terminal `result`
499
+ // field, which repeats the final message and must not be joined with it.
500
+ const addText = (text, source = 'content') => {
501
+ if (typeof text === 'string' && text) events.push({ kind: 'text', text, source, sequence: sequence++ });
497
502
  };
498
503
  const addTool = (event, part) => {
499
504
  const type = eventType(event);
@@ -533,7 +538,7 @@ function parseTranscriptEvents(transcriptPath) {
533
538
  }
534
539
  if (value.message && value.message.content) visit(value.message.content, value.type !== 'user');
535
540
  if (value.content && value.content !== value.message) visit(value.content, includeText);
536
- if (!value.part && typeof value.result === 'string' && includeText) addText(value.result);
541
+ if (!value.part && typeof value.result === 'string' && includeText) addText(value.result, 'result');
537
542
  };
538
543
  let structured = false;
539
544
  for (const line of raw.split('\n')) {
@@ -548,7 +553,7 @@ function parseTranscriptEvents(transcriptPath) {
548
553
  if (!structured) {
549
554
  try {
550
555
  const value = JSON.parse(raw);
551
- if (value && typeof value.result === 'string') addText(value.result);
556
+ if (value && typeof value.result === 'string') addText(value.result, 'result');
552
557
  else visit(value);
553
558
  } catch { /* raw fallback handled by renderAgentTrace */ }
554
559
  }
@@ -644,6 +649,60 @@ function isExecutionLikeTraceValue(value) {
644
649
  || /(?:^|[/\\])[\w.-]+\.(?:c?js|mjs|py|sh|ps1|bat|cmd)\b/i.test(text);
645
650
  }
646
651
 
652
+ // Harness turn-label lines are hidden bookkeeping (AGENTS.md rule 14) and are
653
+ // ignored when judging whether the final message is JSON-only.
654
+ const HARNESS_LABEL_LINE_RE = /^[ \t]*<!--\s*harness-label\b.*?-->[ \t\r]*$/gm;
655
+
656
+ // Pure check for `final_output_json_only`: the final assistant text must be
657
+ // exactly one JSON object, raw or inside a single fenced block, with no prose.
658
+ function checkJsonOnlyOutput(text, requiredKeys = []) {
659
+ const fail = reason => ({ pass: false, reason });
660
+ if (typeof text !== 'string') return fail('final assistant text is missing');
661
+ const body = text.replace(HARNESS_LABEL_LINE_RE, '').trim();
662
+ if (!body) return fail('final assistant text is empty');
663
+ let json;
664
+ if (body.startsWith('```')) {
665
+ const fence = /^```(?:json)?[ \t]*\r?\n([\s\S]*)\r?\n```$/i.exec(body);
666
+ if (!fence) return fail('final output has text outside the single JSON code fence');
667
+ if (fence[1].includes('```')) return fail('final output contains more than one code fence');
668
+ json = fence[1];
669
+ } else if (body.startsWith('{') || body.startsWith('[')) {
670
+ json = body;
671
+ } else {
672
+ try { JSON.parse(body); }
673
+ catch { return fail('final output has prose before the JSON object'); }
674
+ return fail('final JSON value must be an object');
675
+ }
676
+ let value;
677
+ try { value = JSON.parse(json); }
678
+ catch (error) { return fail(`final output is not a single valid JSON value (${error.message}); prose after the JSON also fails`); }
679
+ if (value === null || typeof value !== 'object' || Array.isArray(value)) return fail('final JSON value must be an object');
680
+ const missing = requiredKeys.filter(key => !Object.prototype.hasOwnProperty.call(value, key));
681
+ if (missing.length) return fail(`missing required JSON keys: ${missing.join(', ')}`);
682
+ return { pass: true, reason: null };
683
+ }
684
+
685
+ // The final assistant message is every assistant text part after the last tool
686
+ // event. A final turn may stream prose and JSON as separate parts, so all parts
687
+ // count (#308). Parts can be transport chunks of one message (exact
688
+ // concatenation) or separate complete blocks (newline-separated). Candidates
689
+ // are returned exact-concatenation first. Claude's terminal `result` repeats
690
+ // that message, so it is used only when the transcript has no content parts.
691
+ function finalAssistantTextCandidates(events) {
692
+ let lastToolIndex = -1;
693
+ events.forEach((event, index) => { if (event.kind === 'tool') lastToolIndex = index; });
694
+ const texts = events.slice(lastToolIndex + 1).filter(event => event.kind === 'text');
695
+ const parts = texts.filter(event => event.source !== 'result');
696
+ if (!parts.length) return [texts.length ? texts[texts.length - 1].text : ''];
697
+ const candidates = [parts.map(event => event.text).join(''), parts.map(event => event.text).join('\n')];
698
+ return [...new Set(candidates)];
699
+ }
700
+
701
+ // Exact text of the final message (first candidate).
702
+ function finalAssistantText(events) {
703
+ return finalAssistantTextCandidates(events)[0];
704
+ }
705
+
647
706
  function grade(c, ws, transcriptPath, engine = 'auto') {
648
707
  const parsed = parseTranscriptFile(transcriptPath, engine);
649
708
  const ordered = parseTranscriptEvents(transcriptPath);
@@ -675,6 +734,23 @@ function grade(c, ws, transcriptPath, engine = 'auto') {
675
734
  pass = e.type === 'trace_contains' ? trace.includes(e.value) : !trace.includes(e.value);
676
735
  status = pass ? 'pass' : 'fail';
677
736
  }
737
+ } else if (e.type === 'final_output_json_only') {
738
+ if (parsed.parseStatus !== 'parsed') {
739
+ status = 'inconclusive';
740
+ reason = parsed.parseError || 'transcript parser did not produce a complete structured trace';
741
+ } else {
742
+ // Neither join can hide prose: prose stays outside the JSON either way.
743
+ // Exact concatenation covers transport chunks of one JSON message;
744
+ // newline join covers separate complete blocks such as a fenced JSON
745
+ // block followed by a separate `<!-- harness-label ... -->` part.
746
+ // PASS if any candidate passes; otherwise report the exact-text reason.
747
+ const verdicts = finalAssistantTextCandidates(ordered.events)
748
+ .map(candidate => checkJsonOnlyOutput(candidate, e.required_keys || []));
749
+ const verdict = verdicts.find(v => v.pass) || verdicts[0];
750
+ pass = verdict.pass;
751
+ status = pass ? 'pass' : 'fail';
752
+ reason = verdict.reason;
753
+ }
678
754
  } else if (e.type === 'file_contains') {
679
755
  pass = fs.readFileSync(path.join(ws, e.path), 'utf8').includes(e.value);
680
756
  status = pass ? 'pass' : 'fail';
@@ -998,6 +1074,9 @@ module.exports = {
998
1074
  extractAgentEvents,
999
1075
  extractAgentTrace,
1000
1076
  extractSessionMetadata,
1077
+ checkJsonOnlyOutput,
1078
+ finalAssistantText,
1079
+ finalAssistantTextCandidates,
1001
1080
  grade,
1002
1081
  parseTranscriptEvents,
1003
1082
  pairVerdict,
@@ -55,9 +55,9 @@ class ReadMetricTest(unittest.TestCase):
55
55
 
56
56
 
57
57
  class AccountingTest(unittest.TestCase):
58
- def episode(self, root, engine='claude', workload='profile-lookup', events=None):
58
+ def episode(self, root, engine='claude', workload='profile-lookup', events=None, usage=None):
59
59
  row = {'model': {'engine': engine, 'model': 'fixture'}, 'workload': workload,
60
- 'grader': {'pass': False}, 'usage': None, 'router_attributed': True}
60
+ 'grader': {'pass': False}, 'usage': usage, 'router_attributed': True}
61
61
  (root/'analysis.json').write_text(json.dumps(row))
62
62
  (root/'manifest.json').write_text('{}')
63
63
  (root/'transcript.jsonl').write_text('\n'.join(json.dumps(e) for e in (events or [])))
@@ -81,6 +81,64 @@ class AccountingTest(unittest.TestCase):
81
81
  self.assertIsNone(row['api_equivalent_cost_usd'])
82
82
  self.assertIn('unknown', row['child_usage_observability'])
83
83
 
84
+ def codex_episode(self, root, spawn):
85
+ usage = {'input_tokens': 3000, 'cached_input_tokens': 700, 'output_tokens': 130,
86
+ 'cache_write_input_tokens': 0}
87
+ self.episode(root, engine='codex', usage=usage,
88
+ events=[{'type': 'thread.started', 'thread_id': 'T1'}])
89
+ rollout = root/'.codex'/'sessions'/'2026'/'10'/'14'/'rollout-T1.jsonl'
90
+ rollout.parent.mkdir(parents=True)
91
+ lines = []
92
+ if spawn:
93
+ lines.append({'type': 'response_item', 'payload': {'type': 'function_call', 'name': 'spawn_agent',
94
+ 'call_id': 'c1', 'arguments': json.dumps({'task_name': 'cold_verifier',
95
+ 'fork_turns': 'none', 'reasoning_effort': 'high'})}})
96
+ lines.append({'type': 'response_item', 'payload': {'type': 'function_call_output',
97
+ 'call_id': 'c1', 'output': '{"agent_id": "w1"}'}})
98
+ counters = [
99
+ ({'input_tokens': 1000, 'cached_input_tokens': 200, 'output_tokens': 50, 'cache_write_input_tokens': 0},
100
+ {'input_tokens': 1000, 'cached_input_tokens': 200, 'output_tokens': 50, 'cache_write_input_tokens': 0}),
101
+ ({'input_tokens': 3000, 'cached_input_tokens': 700, 'output_tokens': 130, 'cache_write_input_tokens': 0},
102
+ {'input_tokens': 2000, 'cached_input_tokens': 500, 'output_tokens': 80, 'cache_write_input_tokens': 0}),
103
+ ]
104
+ for total, last in counters:
105
+ lines.append({'type': 'event_msg', 'payload': {'type': 'token_count',
106
+ 'info': {'total_token_usage': total, 'last_token_usage': last}}})
107
+ rollout.write_text('\n'.join(json.dumps(x) for x in lines))
108
+
109
+ def test_codex_spawn_agent_child_cost_is_unknown_not_zero(self):
110
+ with tempfile.TemporaryDirectory() as directory:
111
+ root = Path(directory)
112
+ self.codex_episode(root, spawn=True)
113
+ with patch.object(Path, 'home', return_value=root):
114
+ row = analyze.inspect_episode(root, documents={})
115
+ self.assertIsNone(row['api_equivalent_cost_usd'])
116
+ self.assertIsNone(row['api_equivalent_input_cost_usd'])
117
+ self.assertIsNone(row['reconstructed_total_cost_usd'])
118
+ self.assertIn('unknown', row['child_usage_observability'])
119
+ self.assertEqual(len(row['private_collaboration_dispatch']), 1)
120
+ dispatch = row['private_collaboration_dispatch'][0]
121
+ self.assertEqual(dispatch['tool'], 'spawn_agent')
122
+ self.assertEqual(dispatch['arguments']['task_name'], 'cold_verifier')
123
+ self.assertEqual(len(dispatch['outputs']), 1)
124
+ # Parent cost is measured and retained; worker cost is never zero-filled.
125
+ self.assertIsInstance(row['parent_api_equivalent_input_cost_usd'], float)
126
+ self.assertAlmostEqual(row['parent_api_equivalent_input_cost_usd'], 0.000237)
127
+ self.assertIsInstance(row['parent_api_equivalent_cost_usd'], float)
128
+ self.assertAlmostEqual(row['parent_api_equivalent_cost_usd'], 0.000302)
129
+
130
+ def test_codex_without_spawn_keeps_measured_cost_and_no_unknown_child(self):
131
+ with tempfile.TemporaryDirectory() as directory:
132
+ root = Path(directory)
133
+ self.codex_episode(root, spawn=False)
134
+ with patch.object(Path, 'home', return_value=root):
135
+ row = analyze.inspect_episode(root, documents={})
136
+ self.assertEqual(row['private_collaboration_dispatch'], [])
137
+ self.assertEqual(row['child_usage_observability'], 'no attributable native spawn in inspected parent rollout')
138
+ self.assertAlmostEqual(row['api_equivalent_input_cost_usd'], 0.000237)
139
+ self.assertAlmostEqual(row['api_equivalent_cost_usd'], 0.000302)
140
+ self.assertAlmostEqual(row['reconstructed_total_cost_usd'], 0.000302)
141
+
84
142
  def test_polyglot_grader_timeout_is_retained_failure(self):
85
143
  with tempfile.TemporaryDirectory() as directory:
86
144
  root = Path(directory)
@@ -5,8 +5,8 @@ const fs = require('fs');
5
5
  const os = require('os');
6
6
  const path = require('path');
7
7
  const { parseSimpleYaml } = require('../behavioral-evals/run');
8
- const { grade, parseTranscriptEvents } = require('../behavioral-evals/run');
9
- const { validateCase, validateDirectory, negativeControls } = require('../behavioral-evals/case-validator');
8
+ const { grade, parseTranscriptEvents, checkJsonOnlyOutput, finalAssistantText } = require('../behavioral-evals/run');
9
+ const { validateCase, validateDirectory, validateExpectation, negativeControls } = require('../behavioral-evals/case-validator');
10
10
  const { canonicalRubric, extractTrace } = require('./ab-test-harness');
11
11
 
12
12
  const casesDir = path.join(__dirname, '..', 'behavioral-evals', 'cases');
@@ -85,4 +85,149 @@ try {
85
85
  fs.rmSync(transcriptDir, { recursive: true, force: true });
86
86
  }
87
87
 
88
- console.log(`Behavioral case validation passed (${fs.readdirSync(casesDir).filter(name => name.endsWith('.yaml')).length} cases + negative controls + ordered execution evidence).`);
88
+ // final_output_json_only: pure checker, validator schema, shipped #308 case, and
89
+ // end-to-end grading over the final assistant message.
90
+ const SELECTION_KEYS = ['requestedProfile', 'effectiveProfile', 'profileAlias', 'assignedRole', 'runtimeModel', 'runtimeEffort'];
91
+ const SELECTION = { requestedProfile: 'fable', effectiveProfile: 'sonnet', profileAlias: 'fable-on-sonnet', assignedRole: 'worker', runtimeModel: 'claude-haiku-5-5', runtimeEffort: 'low' };
92
+ const LABEL = '<!-- harness-label {"judgement":"done","routing":"direct"} -->';
93
+ const RAW = JSON.stringify(SELECTION);
94
+ const FENCED = '```json\n' + JSON.stringify(SELECTION, null, 2) + '\n```';
95
+ const PASS_CASES = [
96
+ ['raw JSON object', RAW],
97
+ ['fenced json block', FENCED],
98
+ ['untagged fence', '```\n' + RAW + '\n```'],
99
+ ['raw JSON followed by harness-label line', RAW + '\n' + LABEL],
100
+ ['fenced JSON followed by harness-label line', FENCED + '\n' + LABEL],
101
+ ];
102
+ for (const [name, text] of PASS_CASES) {
103
+ const verdict = checkJsonOnlyOutput(text, SELECTION_KEYS);
104
+ assert.strictEqual(verdict.pass, true, `${name} should pass: ${verdict.reason}`);
105
+ }
106
+ const FAIL_CASES = [
107
+ // The #308 pilot: correct fields, explanatory prose outside the JSON.
108
+ ['prose before JSON (#308)', `"Fable on sonnect" means the fable profile runs on sonnet.\n\n${FENCED}`, /prose before/],
109
+ ['prose after fence', `${FENCED}\nThat is the selection record.`, /outside the single JSON code fence/],
110
+ ['prose after raw JSON', `${RAW}\nThat is the selection record.`, /not a single valid JSON value/],
111
+ ['two fenced blocks', `${FENCED}\n${FENCED}`, /more than one code fence/],
112
+ ['missing required key', JSON.stringify({ ...SELECTION, runtimeEffort: undefined }), /missing required JSON keys: runtimeEffort/],
113
+ ['non-object JSON (array)', JSON.stringify([SELECTION]), /must be an object/],
114
+ ['non-object JSON (scalar)', '"fable"', /must be an object/],
115
+ ['empty final text', '', /empty/],
116
+ ['label-only final text', LABEL, /empty/],
117
+ ['missing final text', undefined, /missing/],
118
+ ];
119
+ for (const [name, text, expected] of FAIL_CASES) {
120
+ const verdict = checkJsonOnlyOutput(text, SELECTION_KEYS);
121
+ assert.strictEqual(verdict.pass, false, `${name} should fail`);
122
+ assert.match(verdict.reason, expected, `${name} should give a clear reason`);
123
+ }
124
+ assert.strictEqual(checkJsonOnlyOutput(RAW).pass, true, 'required_keys defaults to no key requirement');
125
+
126
+ for (const requiredKeys of ['requestedProfile', [''], [' '], [1], ['ok', null]]) {
127
+ const errors = validateExpectation({ type: 'final_output_json_only', required_keys: requiredKeys }, 0);
128
+ assert.ok(errors.some(error => /required_keys must be an array of non-empty strings/.test(error)), `required_keys ${JSON.stringify(requiredKeys)} should be rejected`);
129
+ }
130
+ assert.deepStrictEqual(validateExpectation({ type: 'final_output_json_only' }, 0), [], 'final_output_json_only without required_keys is valid');
131
+ assert.deepStrictEqual(validateExpectation({ type: 'final_output_json_only', required_keys: SELECTION_KEYS }, 0), [], 'final_output_json_only with string keys is valid');
132
+
133
+ const profileCasePath = path.join(casesDir, 'baseline-profile-lookup-json-only.yaml');
134
+ const profileCase = parseSimpleYaml(fs.readFileSync(profileCasePath, 'utf8'));
135
+ assert.deepStrictEqual(validateCase(profileCase, { fileName: 'baseline-profile-lookup-json-only.yaml' }), [], 'profile-lookup JSON-only case should validate');
136
+ assert.deepStrictEqual(profileCase.expectations.map(expectation => expectation.type), ['final_output_json_only'], 'profile-lookup case should assert JSON-only output');
137
+ assert.deepStrictEqual(profileCase.expectations[0].required_keys, SELECTION_KEYS, 'profile-lookup case should require the six selection keys');
138
+ assert.match(profileCase.prompt, /return only a small JSON selection record with requestedProfile, effectiveProfile, profileAlias, assignedRole, runtimeModel, runtimeEffort\. No orchestration or delegation\./, 'profile-lookup prompt should match the #308 wording');
139
+
140
+ const jsonOnlyDir = fs.mkdtempSync(path.join(os.tmpdir(), 'behavioral-json-only-'));
141
+ try {
142
+ const writeTranscript = (name, finalText) => {
143
+ const transcriptPath = path.join(jsonOnlyDir, name);
144
+ const events = [
145
+ { type: 'tool_use', part: { type: 'tool', tool: 'Read', callID: 'read-1', state: { status: 'completed', input: { file_path: 'README.md' } } } },
146
+ { type: 'text', part: { type: 'text', text: 'Checking the profile table first.' } },
147
+ { type: 'tool_use', part: { type: 'tool', tool: 'Read', callID: 'read-2', state: { status: 'completed', input: { file_path: 'profiles.md' } } } },
148
+ { type: 'text', part: { type: 'text', text: finalText } },
149
+ ];
150
+ fs.writeFileSync(transcriptPath, events.map(event => JSON.stringify(event)).join('\n'));
151
+ return transcriptPath;
152
+ };
153
+ const jsonOnlyExpectation = { expectations: [{ type: 'final_output_json_only', required_keys: SELECTION_KEYS }] };
154
+ let transcriptCount = 0;
155
+ const gradeFinal = finalText => grade(jsonOnlyExpectation, jsonOnlyDir, writeTranscript(`final-${transcriptCount++}.jsonl`, finalText));
156
+ assert.strictEqual(gradeFinal(RAW).passed, true, 'raw JSON final message should pass end to end');
157
+ assert.strictEqual(gradeFinal(`${FENCED}\n${LABEL}`).passed, true, 'fenced JSON with harness-label should pass end to end');
158
+ const prose = gradeFinal(`Fable on sonnect means a profile.\n${FENCED}`);
159
+ assert.strictEqual(prose.passed, false, 'prose before JSON must fail end to end');
160
+ assert.strictEqual(prose.results[0].status, 'fail');
161
+ assert.match(prose.results[0].reason, /prose before/);
162
+ // Prose from an earlier turn (before the last tool call) is not the final message.
163
+ const earlierProse = writeTranscript('earlier-prose.jsonl', RAW);
164
+ assert.strictEqual(grade(jsonOnlyExpectation, jsonOnlyDir, earlierProse).passed, true, 'only the final assistant message is graded');
165
+
166
+ // Multi-part final messages: the final turn may stream several text parts.
167
+ const writeParts = (name, finalParts) => {
168
+ const transcriptPath = path.join(jsonOnlyDir, name);
169
+ const events = [
170
+ { type: 'tool_use', part: { type: 'tool', tool: 'Read', callID: 'read-1', state: { status: 'completed', input: { file_path: 'profiles.md' } } } },
171
+ ...finalParts.map(text => ({ type: 'text', part: { type: 'text', text } })),
172
+ ];
173
+ fs.writeFileSync(transcriptPath, events.map(event => JSON.stringify(event)).join('\n'));
174
+ return transcriptPath;
175
+ };
176
+ const proseThenJson = grade(jsonOnlyExpectation, jsonOnlyDir, writeParts('prose-part-json-part.jsonl', ['"Fable on sonnect" means the fable profile on sonnet.', FENCED]));
177
+ assert.strictEqual(proseThenJson.passed, false, 'prose part + JSON part must fail (#308 false pass)');
178
+ assert.match(proseThenJson.results[0].reason, /prose before/);
179
+ const jsonThenProse = grade(jsonOnlyExpectation, jsonOnlyDir, writeParts('json-part-prose-part.jsonl', [FENCED, 'That is the selection record.']));
180
+ assert.strictEqual(jsonThenProse.passed, false, 'JSON part + prose part must fail');
181
+ const splitBody = JSON.stringify(SELECTION, null, 2);
182
+ const cut = splitBody.indexOf('"assignedRole"');
183
+ const splitJson = grade(jsonOnlyExpectation, jsonOnlyDir, writeParts('split-json.jsonl', ['```json\n' + splitBody.slice(0, cut), splitBody.slice(cut) + '\n```']));
184
+ assert.strictEqual(splitJson.passed, true, `JSON split across text parts should pass: ${splitJson.results[0].reason}`);
185
+
186
+ // Transport chunks cut inside a JSON string value, a key, and the fence opener.
187
+ // Exact concatenation must rebuild the message; a '\n' join would break it.
188
+ const valueCut = RAW.indexOf('fable') + 2;
189
+ const keyCut = RAW.indexOf('requestedProfile') + 5;
190
+ const splitValueParts = [RAW.slice(0, valueCut), RAW.slice(valueCut)];
191
+ const splitValuePath = writeParts('split-inside-value.jsonl', splitValueParts);
192
+ assert.strictEqual(finalAssistantText(parseTranscriptEvents(splitValuePath).events), RAW, 'split-inside-value parts must concatenate to the exact raw JSON');
193
+ assert.throws(() => JSON.parse(RAW.slice(0, valueCut) + '\n' + RAW.slice(valueCut)), 'a newline join inside a JSON string must not parse (the #308 reviewer case)');
194
+ const POSITIVE_SPLITS = [
195
+ ['split inside a JSON string value', splitValueParts],
196
+ ['split inside a JSON key', [RAW.slice(0, keyCut), RAW.slice(keyCut)]],
197
+ ['split inside the fence opener', ['```js', 'on\n' + JSON.stringify(SELECTION, null, 2) + '\n```']],
198
+ ['fenced JSON block followed by a harness-label part', [FENCED, LABEL]],
199
+ ];
200
+ POSITIVE_SPLITS.forEach(([name, parts], index) => {
201
+ const result = grade(jsonOnlyExpectation, jsonOnlyDir, writeParts(`positive-split-${index}.jsonl`, parts));
202
+ assert.strictEqual(result.passed, true, `${name} should pass end to end: ${result.results[0].reason}`);
203
+ });
204
+ const NEGATIVE_SPLITS = [
205
+ ['prose chunk glued to JSON start', ['Here:', RAW], /prose before/],
206
+ ['prose after a split JSON', [RAW.slice(0, 10), RAW.slice(10), ' done'], /not a single valid JSON value/],
207
+ ];
208
+ NEGATIVE_SPLITS.forEach(([name, parts, expected], index) => {
209
+ const result = grade(jsonOnlyExpectation, jsonOnlyDir, writeParts(`negative-split-${index}.jsonl`, parts));
210
+ assert.strictEqual(result.passed, false, `${name} must fail end to end`);
211
+ assert.match(result.results[0].reason, expected, `${name} should give a clear reason`);
212
+ });
213
+
214
+ // Claude stream-json: assistant content parts plus a terminal `result` that
215
+ // repeats them. The repeat must not be double-counted, and must not mask prose.
216
+ const writeClaude = (name, contentTexts, resultText) => {
217
+ const transcriptPath = path.join(jsonOnlyDir, name);
218
+ const lines = [
219
+ { type: 'assistant', message: { content: [{ type: 'tool_use', id: 'tu-1', name: 'Read', input: { file_path: 'profiles.md' } }] } },
220
+ { type: 'user', message: { content: [{ type: 'tool_result', tool_use_id: 'tu-1', content: 'ok' }] } },
221
+ { type: 'assistant', message: { content: contentTexts.map(text => ({ type: 'text', text })) } },
222
+ { type: 'result', subtype: 'success', result: resultText },
223
+ ];
224
+ fs.writeFileSync(transcriptPath, lines.map(line => JSON.stringify(line)).join('\n'));
225
+ return transcriptPath;
226
+ };
227
+ assert.strictEqual(grade(jsonOnlyExpectation, jsonOnlyDir, writeClaude('claude-result-repeat.jsonl', [FENCED], FENCED)).passed, true, 'Claude result repeat must not double the JSON');
228
+ assert.strictEqual(grade(jsonOnlyExpectation, jsonOnlyDir, writeClaude('claude-prose-parts.jsonl', ['Here is the record.', FENCED], FENCED)).passed, false, 'Claude prose content part must fail even when result holds only JSON');
229
+ } finally {
230
+ fs.rmSync(jsonOnlyDir, { recursive: true, force: true });
231
+ }
232
+
233
+ console.log(`Behavioral case validation passed (${fs.readdirSync(casesDir).filter(name => name.endsWith('.yaml')).length} cases + negative controls + ordered execution evidence + json-only final output).`);
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "harness-enforcement",
3
3
  "description": "Repository inventory for the opencode enforcement plugin and its standalone helper",
4
- "version": "0.47.3",
4
+ "version": "0.47.4",
5
5
  "runtime": "index.mjs",
6
6
  "hooks": {
7
7
  "verify": "hooks/verify.js"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "harness-everything",
3
- "version": "0.47.3",
3
+ "version": "0.47.4",
4
4
  "description": "Harness OS: An Orchestrated Agent Operating System for AI-driven Development",
5
5
  "main": "harness-everything/scripts/bootstrap.js",
6
6
  "bin": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "harness-everything",
3
- "version": "0.47.3",
3
+ "version": "0.47.4",
4
4
  "description": "Invariant-first engineering harness for routing, verification, and failure recovery while preserving agent autonomy.",
5
5
  "author": {
6
6
  "name": "Miya Daniel",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "$schema": "https://agent-plugins.org/schemas/1.0.0/plugin.schema.json",
3
3
  "name": "harness-everything",
4
- "version": "0.47.3",
4
+ "version": "0.47.4",
5
5
  "description": "Invariant-first engineering harness for routing, verification, and failure recovery while preserving agent autonomy.",
6
6
  "author": {
7
7
  "name": "Miya Daniel",
package/sbom.cdx.json CHANGED
@@ -2,23 +2,23 @@
2
2
  "$schema": "https://cyclonedx.org/schema/bom-1.7.schema.json",
3
3
  "bomFormat": "CycloneDX",
4
4
  "specVersion": "1.7",
5
- "serialNumber": "urn:uuid:1aa302d2-c2eb-59e7-aed3-a0dae8f57f06",
5
+ "serialNumber": "urn:uuid:9486ee85-4d20-5c13-b980-a501dc02e9fa",
6
6
  "version": 1,
7
7
  "metadata": {
8
- "timestamp": "2026-10-31T16:31:35.736Z",
8
+ "timestamp": "2026-11-03T01:39:16.935Z",
9
9
  "tools": [
10
10
  {
11
11
  "vendor": "Harness Everything",
12
12
  "name": "generate-sbom.js",
13
- "version": "0.47.3"
13
+ "version": "0.47.4"
14
14
  }
15
15
  ],
16
16
  "component": {
17
17
  "type": "application",
18
- "bom-ref": "pkg:npm/harness-everything@0.47.3",
18
+ "bom-ref": "pkg:npm/harness-everything@0.47.4",
19
19
  "name": "harness-everything",
20
- "version": "0.47.3",
21
- "purl": "pkg:npm/harness-everything@0.47.3"
20
+ "version": "0.47.4",
21
+ "purl": "pkg:npm/harness-everything@0.47.4"
22
22
  }
23
23
  },
24
24
  "components": [
@@ -2033,7 +2033,7 @@
2033
2033
  ]
2034
2034
  },
2035
2035
  {
2036
- "ref": "pkg:npm/harness-everything@0.47.3",
2036
+ "ref": "pkg:npm/harness-everything@0.47.4",
2037
2037
  "dependsOn": [
2038
2038
  "pkg:npm/%40commitlint/cli@19.8.1",
2039
2039
  "pkg:npm/%40commitlint/config-conventional@19.8.1",