harness-everything 0.47.3 → 0.47.5
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 +24 -0
- package/behavioral-evals/README.md +27 -0
- package/behavioral-evals/case-validator.js +22 -1
- package/behavioral-evals/cases/baseline-profile-lookup-json-only.yaml +23 -0
- package/behavioral-evals/cases/baseline-tdd-workflow.yaml +4 -0
- package/behavioral-evals/run.js +257 -6
- package/behavioral-evals/transcript-parser.js +43 -0
- package/benchmarks/issue290-pilot/metrics.test.py +60 -2
- package/ci/mechanism-2s-tdd-quality.test.js +149 -6
- package/ci/test-behavioral-case-validation.js +362 -3
- package/code-review/SKILL.md +1 -1
- package/code-review/references/review-playbook.md +9 -0
- package/opencode-plugin/plugin.json +1 -1
- package/package.json +1 -1
- package/plugins/harness-everything/.codex-plugin/plugin.json +1 -1
- package/plugins/harness-everything/plugin.json +1 -1
- package/plugins/harness-everything/skills/code-review/SKILL.md +1 -1
- package/plugins/harness-everything/skills/code-review/references/review-playbook.md +9 -0
- package/plugins/harness-everything/skills/tdd/SKILL.md +1 -1
- package/plugins/harness-everything/skills/tdd/fixtures/quality-scenarios.json +10 -2
- package/plugins/harness-everything/skills/tdd/references/quality-model.md +2 -0
- package/plugins/harness-everything/skills/tdd/schemas/quality-evidence.schema.json +21 -0
- package/plugins/harness-everything/skills/tdd/schemas/quality-report.schema.json +2 -0
- package/plugins/harness-everything/skills/tdd/scripts/quality-gate.js +165 -1
- package/plugins/harness-everything/skills/zoom-out/SKILL.md +1 -1
- package/plugins/harness-everything/skills/zoom-out/references/circuit-breaker.md +1 -0
- package/sbom.cdx.json +7 -7
- package/tdd/SKILL.md +1 -1
- package/tdd/fixtures/quality-scenarios.json +10 -2
- package/tdd/references/quality-model.md +2 -0
- package/tdd/schemas/quality-evidence.schema.json +21 -0
- package/tdd/schemas/quality-report.schema.json +2 -0
- package/tdd/scripts/quality-gate.js +165 -1
- package/zoom-out/SKILL.md +1 -1
- package/zoom-out/references/circuit-breaker.md +1 -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.47.
|
|
13
|
+
"version": "0.47.5",
|
|
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.
|
|
4
|
+
"version": "0.47.5",
|
|
5
5
|
"author": {
|
|
6
6
|
"name": "Miya Daniel"
|
|
7
7
|
},
|
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,28 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to this project are documented in this file.
|
|
4
4
|
|
|
5
|
+
## [0.47.5](https://github.com/dyphn1/Harness-everything/compare/v0.47.4...v0.47.5) (2026-11-03)
|
|
6
|
+
|
|
7
|
+
### Bug Fixes
|
|
8
|
+
|
|
9
|
+
* **behavioral-evals:** detect npm ERR! lines in quality gate output ([b599224](https://github.com/dyphn1/Harness-everything/commit/b599224ba09f27dc1f5e5c1156c40c20e57f6f52))
|
|
10
|
+
* **behavioral-evals:** grade tdd quality gate on independently re-evaluated evidence ([f9433ed](https://github.com/dyphn1/Harness-everything/commit/f9433ed535ba4b1882029622a31325ae564cdd50)), closes [#311](https://github.com/dyphn1/Harness-everything/issues/311)
|
|
11
|
+
* **behavioral-evals:** grade the last quality gate attempt, not the last completed run ([8065345](https://github.com/dyphn1/Harness-everything/commit/806534592dbc47665868e74a36b4095d44a2647b))
|
|
12
|
+
* **behavioral-evals:** order gate workspace checks before outcome and compare printed report ([ded48fd](https://github.com/dyphn1/Harness-everything/commit/ded48fd7221cb66c61cc58a70c1f4924f0c0fc82))
|
|
13
|
+
* **behavioral-evals:** require observed gate success and full retained report ([47d35d7](https://github.com/dyphn1/Harness-everything/commit/47d35d725623a4355dcc8a054347ec68832edaa2))
|
|
14
|
+
* **tdd:** keep source defect evidence reviewer-required ([443e966](https://github.com/dyphn1/Harness-everything/commit/443e96635c972713acd9fa76e974cb79bfa310ff))
|
|
15
|
+
* **tdd:** read assert.* expected from second argument; tolerate trailing semicolon ([1745ea2](https://github.com/dyphn1/Harness-everything/commit/1745ea2860a2566703af93ec23fc1ff1b00d7eef))
|
|
16
|
+
* **tdd:** require quoted assertion evidence for source conflict claims ([e9e1ab9](https://github.com/dyphn1/Harness-everything/commit/e9e1ab9b8170054a1ecf57b440b18124b4f99108)), closes [#312](https://github.com/dyphn1/Harness-everything/issues/312)
|
|
17
|
+
* **tdd:** require same-subject assertions for SOURCE_CONFLICT evidence ([d0ab25f](https://github.com/dyphn1/Harness-everything/commit/d0ab25fb810f390921acc9f38584eead95f18214))
|
|
18
|
+
* **tdd:** verify sourceEvidence expected values against quoted literals ([77a8ba6](https://github.com/dyphn1/Harness-everything/commit/77a8ba690de69db9852371270e86c063d30b77ed))
|
|
19
|
+
|
|
20
|
+
## [0.47.4](https://github.com/dyphn1/Harness-everything/compare/v0.47.3...v0.47.4) (2026-11-03)
|
|
21
|
+
|
|
22
|
+
### Bug Fixes
|
|
23
|
+
|
|
24
|
+
* **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)
|
|
25
|
+
* **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)
|
|
26
|
+
|
|
5
27
|
## [0.47.3](https://github.com/dyphn1/Harness-everything/compare/v0.47.2...v0.47.3) (2026-10-31)
|
|
6
28
|
|
|
7
29
|
### Bug Fixes
|
|
@@ -46,6 +68,8 @@ All notable changes to this project are documented in this file.
|
|
|
46
68
|
|
|
47
69
|
### Bug Fixes
|
|
48
70
|
|
|
71
|
+
* **code-review:** add evidence-gate counterexamples (claimed vs observed, select then judge, one witness per rule, comparable subjects and honest labels) for reviewers and author self-review.
|
|
72
|
+
* **zoom-out:** the author applying review fixes counts repair-loop rounds too; a decision class repeated from the previous round triggers Phase 2 before editing.
|
|
49
73
|
* **doc-reconciliation:** pin the owner-approved VERI-to-EVID row mapping and exclude ordinary SubagentStop completions from quality-review candidates.
|
|
50
74
|
* **doc-reconciliation:** allow uniquely identified same-root reviewers that start after the correlated draft worker stops.
|
|
51
75
|
|
|
@@ -168,9 +168,36 @@ command ran: final prose and attempted tool inputs can mention an action that
|
|
|
168
168
|
never completed. Use structured execution expectations instead, for example
|
|
169
169
|
`tool_completed` with `command: "npm test"` (command prefixes may include
|
|
170
170
|
additional arguments) or `tool_denied` when denial is the behavior under test.
|
|
171
|
+
When one completion must accept several explicit command shapes, use
|
|
172
|
+
`value: {command_pattern: "<regex>"}`; the regex is matched against the whole
|
|
173
|
+
command, so shell control operators must be excluded explicitly.
|
|
174
|
+
`tdd_quality_gate` takes the same `command_pattern` but does not trust the tool
|
|
175
|
+
call's completed status. For the last matching run the grader first runs the
|
|
176
|
+
workspace checks: the evidence file must be retained, valid JSON and re-evaluate
|
|
177
|
+
to `PASS` with this repository's `tdd/scripts/quality-gate.js`; a `--output`
|
|
178
|
+
report must be present and deep-equal the fresh re-evaluation over every
|
|
179
|
+
top-level key (key order ignored); and a full report printed to stdout must do
|
|
180
|
+
the same. Missing, invalid or disagreeing artifacts are definitive `fail`s, and
|
|
181
|
+
a missing required key or a differing key is named in the reason. Then it reads
|
|
182
|
+
the observed command outcome: the call fails when it reports an error, exits
|
|
183
|
+
non-zero, prints an npm error line (`npm ERR!`, `npm error`, or `Missing script:`)
|
|
184
|
+
or prints `TDD Quality Gate: FAIL`. It passes only when the output contains the
|
|
185
|
+
gate's own `TDD Quality Gate: PASS` line and none of those signals. When the
|
|
186
|
+
transcript shows neither output nor an exit code, the result is `inconclusive`
|
|
187
|
+
rather than a pass. The last matching attempt counts, in transcript order: a later failed or denied run fails the gate, and a later unresolved run is inconclusive, even when an earlier run passed.
|
|
171
188
|
Legacy one-object Claude JSON and incomplete streams are graded inconclusive
|
|
172
189
|
for execution evidence.
|
|
173
190
|
|
|
191
|
+
Use `final_output_json_only` when the final message must be only a JSON object
|
|
192
|
+
(raw, or inside one fenced block) with no prose outside it. Hidden
|
|
193
|
+
`<!-- harness-label -->` lines are ignored. Optional `required_keys` lists keys
|
|
194
|
+
that must be present. Extracting the selection fields from prose does not pass.
|
|
195
|
+
The final message is every assistant text part after the last tool call, joined
|
|
196
|
+
by exact concatenation (so JSON streamed in chunks reassembles exactly), with
|
|
197
|
+
newline-separated blocks as an alternative; the case passes if either reading is
|
|
198
|
+
a single JSON output. Claude's repeated `result` is used only when no parts exist.
|
|
199
|
+
Prose streamed in a separate part still fails under both readings.
|
|
200
|
+
|
|
174
201
|
Grading is best-effort mechanical (trace keyword/state assertions), not a
|
|
175
202
|
substitute for reading the transcript — every result JSON records the full
|
|
176
203
|
trace path so humans can audit what the grader concluded.
|
|
@@ -25,6 +25,8 @@ const EXPECTATION_TYPES = new Set([
|
|
|
25
25
|
'tool_completed',
|
|
26
26
|
'tool_denied',
|
|
27
27
|
'execution_evidence',
|
|
28
|
+
'tdd_quality_gate',
|
|
29
|
+
'final_output_json_only',
|
|
28
30
|
]);
|
|
29
31
|
|
|
30
32
|
const PRESSURE_CATEGORIES = new Set([
|
|
@@ -118,13 +120,30 @@ function validateExpectation(expectation, index) {
|
|
|
118
120
|
errors.push(`expectations[${index}] ${type} requires a string value`);
|
|
119
121
|
}
|
|
120
122
|
if (type === 'command_exit_0' && (typeof expectation.command !== 'string' || !expectation.command.trim())) errors.push(`expectations[${index}] command_exit_0 requires a non-empty command`);
|
|
121
|
-
if (
|
|
123
|
+
if (type === 'tdd_quality_gate' && !(isPlainObject(expectation.value) && expectation.value.command_pattern !== undefined)) {
|
|
124
|
+
errors.push(`expectations[${index}] tdd_quality_gate requires value.command_pattern`);
|
|
125
|
+
}
|
|
126
|
+
if (type === 'final_output_json_only' && expectation.required_keys !== undefined) {
|
|
127
|
+
const keys = expectation.required_keys;
|
|
128
|
+
if (!Array.isArray(keys) || keys.some(key => typeof key !== 'string' || !key.trim())) {
|
|
129
|
+
errors.push(`expectations[${index}] required_keys must be an array of non-empty strings`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
if (['tool_attempted', 'tool_executed', 'tool_completed', 'tool_denied', 'execution_evidence', 'tdd_quality_gate'].includes(type)) {
|
|
122
133
|
if (expectation.value === undefined && expectation.tool === undefined && expectation.name === undefined && expectation.command === undefined) {
|
|
123
134
|
errors.push(`expectations[${index}] ${type} requires a tool/value target`);
|
|
124
135
|
}
|
|
125
136
|
if (expectation.value !== undefined && !['string', 'number'].includes(typeof expectation.value) && !isPlainObject(expectation.value)) {
|
|
126
137
|
errors.push(`expectations[${index}] execution target must be scalar or mapping`);
|
|
127
138
|
}
|
|
139
|
+
if (isPlainObject(expectation.value) && expectation.value.command_pattern !== undefined) {
|
|
140
|
+
if (typeof expectation.value.command_pattern !== 'string' || !expectation.value.command_pattern) {
|
|
141
|
+
errors.push(`expectations[${index}] command_pattern must be a non-empty string`);
|
|
142
|
+
} else {
|
|
143
|
+
try { new RegExp(expectation.value.command_pattern); }
|
|
144
|
+
catch { errors.push(`expectations[${index}] command_pattern must be a valid regular expression`); }
|
|
145
|
+
}
|
|
146
|
+
}
|
|
128
147
|
}
|
|
129
148
|
return errors;
|
|
130
149
|
}
|
|
@@ -203,6 +222,8 @@ function negativeControls() {
|
|
|
203
222
|
{ id: 'bad-path', prompt: 'x', max_turns: 1, fixture: { files: [{ path: '../escape.js', content: 'x' }] }, expectations: [{ type: 'trace_contains', value: 'x' }] },
|
|
204
223
|
{ id: 'duplicate-path', prompt: 'x', max_turns: 1, fixture: { files: [{ path: 'x.js', content: '' }, { path: 'x.js', content: '' }] }, expectations: [{ type: 'trace_contains', value: 'x' }] },
|
|
205
224
|
{ id: 'bad-execution', prompt: 'x', max_turns: 1, fixture: { files: [{ path: 'x.js', content: '' }] }, expectations: [{ type: 'tool_executed' }] },
|
|
225
|
+
{ id: 'quality-gate-no-pattern', prompt: 'x', max_turns: 1, fixture: { files: [{ path: 'x.js', content: '' }] }, expectations: [{ type: 'tdd_quality_gate', value: { name: 'Bash' } }] },
|
|
226
|
+
{ id: 'bad-command-pattern', prompt: 'x', max_turns: 1, fixture: { files: [{ path: 'x.js', content: '' }] }, expectations: [{ type: 'tool_executed', value: { command_pattern: '(' } }] },
|
|
206
227
|
];
|
|
207
228
|
return controls.map(control => ({ control, errors: validateCase(control) })).filter(result => result.errors.length === 0);
|
|
208
229
|
}
|
|
@@ -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
|
|
@@ -26,3 +26,7 @@ expectations:
|
|
|
26
26
|
path: "calculator.js"
|
|
27
27
|
value: "divide"
|
|
28
28
|
description: divide function exists
|
|
29
|
+
- type: tdd_quality_gate
|
|
30
|
+
value:
|
|
31
|
+
command_pattern: '^(?:npm (?:run|run-script) (?:(?:--silent|-s|--quiet) )*(?:tdd:quality|test:tdd-quality)|node (?:--no-warnings )?(?:[^\s;&|<>`$()]*/)?tdd/scripts/quality-gate\.js)(?: [^;&|<>`$()\n\r]*)?$'
|
|
32
|
+
description: TDD quality gate passed on retained evidence (independently re-evaluated; skill-core completion, not artifact pass)
|
package/behavioral-evals/run.js
CHANGED
|
@@ -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,8 @@ 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
|
+
'tdd_quality_gate',
|
|
154
|
+
'final_output_json_only',
|
|
153
155
|
]);
|
|
154
156
|
for (const c of cases) {
|
|
155
157
|
const problems = [];
|
|
@@ -165,7 +167,9 @@ function validate(cases) {
|
|
|
165
167
|
if (!resolveTreatmentSkills(c).length) problems.push('treatment must load at least one named Harness skill');
|
|
166
168
|
for (const e of c.expectations || []) {
|
|
167
169
|
if (!EXPECT_TYPES.has(e.type)) problems.push(`unknown expectation type "${e.type}"`);
|
|
168
|
-
if (e.
|
|
170
|
+
if (e.type === 'final_output_json_only') {
|
|
171
|
+
problems.push(...validateExpectation(e, c.expectations.indexOf(e)));
|
|
172
|
+
} else if (e.value === undefined && e.command === undefined && e.tool === undefined && e.name === undefined && e.count === undefined) {
|
|
169
173
|
problems.push(`expectation ${e.type} needs value, tool, name, count, or command`);
|
|
170
174
|
}
|
|
171
175
|
}
|
|
@@ -492,8 +496,10 @@ function parseTranscriptEvents(transcriptPath) {
|
|
|
492
496
|
const events = [];
|
|
493
497
|
const pendingById = new Map();
|
|
494
498
|
let sequence = 0;
|
|
495
|
-
|
|
496
|
-
|
|
499
|
+
// `source` separates assistant content parts from Claude's terminal `result`
|
|
500
|
+
// field, which repeats the final message and must not be joined with it.
|
|
501
|
+
const addText = (text, source = 'content') => {
|
|
502
|
+
if (typeof text === 'string' && text) events.push({ kind: 'text', text, source, sequence: sequence++ });
|
|
497
503
|
};
|
|
498
504
|
const addTool = (event, part) => {
|
|
499
505
|
const type = eventType(event);
|
|
@@ -533,7 +539,7 @@ function parseTranscriptEvents(transcriptPath) {
|
|
|
533
539
|
}
|
|
534
540
|
if (value.message && value.message.content) visit(value.message.content, value.type !== 'user');
|
|
535
541
|
if (value.content && value.content !== value.message) visit(value.content, includeText);
|
|
536
|
-
if (!value.part && typeof value.result === 'string' && includeText) addText(value.result);
|
|
542
|
+
if (!value.part && typeof value.result === 'string' && includeText) addText(value.result, 'result');
|
|
537
543
|
};
|
|
538
544
|
let structured = false;
|
|
539
545
|
for (const line of raw.split('\n')) {
|
|
@@ -548,7 +554,7 @@ function parseTranscriptEvents(transcriptPath) {
|
|
|
548
554
|
if (!structured) {
|
|
549
555
|
try {
|
|
550
556
|
const value = JSON.parse(raw);
|
|
551
|
-
if (value && typeof value.result === 'string') addText(value.result);
|
|
557
|
+
if (value && typeof value.result === 'string') addText(value.result, 'result');
|
|
552
558
|
else visit(value);
|
|
553
559
|
} catch { /* raw fallback handled by renderAgentTrace */ }
|
|
554
560
|
}
|
|
@@ -644,6 +650,227 @@ function isExecutionLikeTraceValue(value) {
|
|
|
644
650
|
|| /(?:^|[/\\])[\w.-]+\.(?:c?js|mjs|py|sh|ps1|bat|cmd)\b/i.test(text);
|
|
645
651
|
}
|
|
646
652
|
|
|
653
|
+
// Harness turn-label lines are hidden bookkeeping (AGENTS.md rule 14) and are
|
|
654
|
+
// ignored when judging whether the final message is JSON-only.
|
|
655
|
+
const HARNESS_LABEL_LINE_RE = /^[ \t]*<!--\s*harness-label\b.*?-->[ \t\r]*$/gm;
|
|
656
|
+
|
|
657
|
+
// Pure check for `final_output_json_only`: the final assistant text must be
|
|
658
|
+
// exactly one JSON object, raw or inside a single fenced block, with no prose.
|
|
659
|
+
function checkJsonOnlyOutput(text, requiredKeys = []) {
|
|
660
|
+
const fail = reason => ({ pass: false, reason });
|
|
661
|
+
if (typeof text !== 'string') return fail('final assistant text is missing');
|
|
662
|
+
const body = text.replace(HARNESS_LABEL_LINE_RE, '').trim();
|
|
663
|
+
if (!body) return fail('final assistant text is empty');
|
|
664
|
+
let json;
|
|
665
|
+
if (body.startsWith('```')) {
|
|
666
|
+
const fence = /^```(?:json)?[ \t]*\r?\n([\s\S]*)\r?\n```$/i.exec(body);
|
|
667
|
+
if (!fence) return fail('final output has text outside the single JSON code fence');
|
|
668
|
+
if (fence[1].includes('```')) return fail('final output contains more than one code fence');
|
|
669
|
+
json = fence[1];
|
|
670
|
+
} else if (body.startsWith('{') || body.startsWith('[')) {
|
|
671
|
+
json = body;
|
|
672
|
+
} else {
|
|
673
|
+
try { JSON.parse(body); }
|
|
674
|
+
catch { return fail('final output has prose before the JSON object'); }
|
|
675
|
+
return fail('final JSON value must be an object');
|
|
676
|
+
}
|
|
677
|
+
let value;
|
|
678
|
+
try { value = JSON.parse(json); }
|
|
679
|
+
catch (error) { return fail(`final output is not a single valid JSON value (${error.message}); prose after the JSON also fails`); }
|
|
680
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) return fail('final JSON value must be an object');
|
|
681
|
+
const missing = requiredKeys.filter(key => !Object.prototype.hasOwnProperty.call(value, key));
|
|
682
|
+
if (missing.length) return fail(`missing required JSON keys: ${missing.join(', ')}`);
|
|
683
|
+
return { pass: true, reason: null };
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
// The final assistant message is every assistant text part after the last tool
|
|
687
|
+
// event. A final turn may stream prose and JSON as separate parts, so all parts
|
|
688
|
+
// count (#308). Parts can be transport chunks of one message (exact
|
|
689
|
+
// concatenation) or separate complete blocks (newline-separated). Candidates
|
|
690
|
+
// are returned exact-concatenation first. Claude's terminal `result` repeats
|
|
691
|
+
// that message, so it is used only when the transcript has no content parts.
|
|
692
|
+
function finalAssistantTextCandidates(events) {
|
|
693
|
+
let lastToolIndex = -1;
|
|
694
|
+
events.forEach((event, index) => { if (event.kind === 'tool') lastToolIndex = index; });
|
|
695
|
+
const texts = events.slice(lastToolIndex + 1).filter(event => event.kind === 'text');
|
|
696
|
+
const parts = texts.filter(event => event.source !== 'result');
|
|
697
|
+
if (!parts.length) return [texts.length ? texts[texts.length - 1].text : ''];
|
|
698
|
+
const candidates = [parts.map(event => event.text).join(''), parts.map(event => event.text).join('\n')];
|
|
699
|
+
return [...new Set(candidates)];
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
// Exact text of the final message (first candidate).
|
|
703
|
+
function finalAssistantText(events) {
|
|
704
|
+
return finalAssistantTextCandidates(events)[0];
|
|
705
|
+
}
|
|
706
|
+
|
|
707
|
+
// --- tdd_quality_gate -------------------------------------------------------
|
|
708
|
+
// A completed tool call only proves the shell returned; it does not prove the
|
|
709
|
+
// gate passed (exit 1 still "completes") or that auditable evidence exists.
|
|
710
|
+
// The grader therefore re-evaluates the evidence the agent pointed the gate at,
|
|
711
|
+
// independently and after the run, with this repository's own evaluator.
|
|
712
|
+
|
|
713
|
+
function splitCommandArgs(command) {
|
|
714
|
+
const args = [];
|
|
715
|
+
const re = /"([^"]*)"|'([^']*)'|(\S+)/g;
|
|
716
|
+
let match;
|
|
717
|
+
while ((match = re.exec(command))) args.push(match[1] ?? match[2] ?? match[3]);
|
|
718
|
+
return args;
|
|
719
|
+
}
|
|
720
|
+
|
|
721
|
+
// Returns { evidence, output } paths named by a quality-gate command, or null
|
|
722
|
+
// when the command names no evidence file.
|
|
723
|
+
function qualityGateArgs(command) {
|
|
724
|
+
const args = splitCommandArgs(String(command || ''));
|
|
725
|
+
const scriptIndex = args.findIndex(arg => /(?:^|[/\\])quality-gate\.js$/.test(arg) || arg === 'tdd:quality' || arg === 'test:tdd-quality');
|
|
726
|
+
if (scriptIndex < 0) return null;
|
|
727
|
+
const rest = args.slice(scriptIndex + 1).filter(arg => arg !== '--');
|
|
728
|
+
let evidence = null;
|
|
729
|
+
let output = null;
|
|
730
|
+
for (let index = 0; index < rest.length; index++) {
|
|
731
|
+
if (rest[index] === '--output') { output = rest[index + 1] || null; index++; continue; }
|
|
732
|
+
if (rest[index].startsWith('-')) continue;
|
|
733
|
+
if (evidence === null) evidence = rest[index];
|
|
734
|
+
}
|
|
735
|
+
return { script: args[scriptIndex], evidence, output };
|
|
736
|
+
}
|
|
737
|
+
|
|
738
|
+
function resolveInWorkspace(ws, file) {
|
|
739
|
+
const root = path.resolve(ws);
|
|
740
|
+
const resolved = path.resolve(root, file);
|
|
741
|
+
return resolved === root || resolved.startsWith(root + path.sep) ? resolved : null;
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
// Judges what the transcript shows the gate command did. A completed status
|
|
745
|
+
// alone is not enough: OpenCode reports "completed" for a gate that exited 1.
|
|
746
|
+
// Returns { status: 'pass' | 'fail' | 'inconclusive', reason }.
|
|
747
|
+
// Line-anchored so prose such as "avoid npm error handling" is not an error.
|
|
748
|
+
const GATE_COMMAND_ERROR_RE = /^(?:npm (?:ERR!|error)(?=\s|$)|.*Missing script:)/m;
|
|
749
|
+
|
|
750
|
+
function isObject(value) {
|
|
751
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
function observedGateOutcome(call) {
|
|
755
|
+
const output = typeof call.output === 'string' ? call.output : '';
|
|
756
|
+
const exitCode = typeof call.exit_code === 'number' ? call.exit_code : null;
|
|
757
|
+
if (call.is_error === true) return { status: 'fail', reason: 'the tool call reported an error' };
|
|
758
|
+
if (exitCode !== null && exitCode !== 0) return { status: 'fail', reason: `the command exited with code ${exitCode}` };
|
|
759
|
+
if (GATE_COMMAND_ERROR_RE.test(output)) return { status: 'fail', reason: 'the command output reports an npm error' };
|
|
760
|
+
if (output.includes('TDD Quality Gate: FAIL')) return { status: 'fail', reason: 'the gate printed TDD Quality Gate: FAIL' };
|
|
761
|
+
if (output.includes('TDD Quality Gate: PASS')) return { status: 'pass', reason: 'the gate printed TDD Quality Gate: PASS' };
|
|
762
|
+
if (!output && exitCode === null) return { status: 'inconclusive', reason: 'quality gate command outcome is not observable in the transcript' };
|
|
763
|
+
return { status: 'inconclusive', reason: 'the gate success line TDD Quality Gate: PASS was not observed in the command output' };
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
// Key-sorted serialisation, so two reports compare equal regardless of key order.
|
|
767
|
+
function stableStringify(value) {
|
|
768
|
+
if (value === undefined) return 'undefined';
|
|
769
|
+
if (value === null || typeof value !== 'object') return JSON.stringify(value);
|
|
770
|
+
if (Array.isArray(value)) return `[${value.map(stableStringify).join(',')}]`;
|
|
771
|
+
return `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${stableStringify(value[key])}`).join(',')}}`;
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
// Key-sorted comparison of two reports. Returns the first top-level key that
|
|
775
|
+
// differs, or undefined when they are equal. Shared by the --output file and
|
|
776
|
+
// the report the gate prints to stdout.
|
|
777
|
+
function firstDifferingKey(actual, expected) {
|
|
778
|
+
return [...new Set([...Object.keys(actual), ...Object.keys(expected)])].sort()
|
|
779
|
+
.find(key => stableStringify(actual[key]) !== stableStringify(expected[key]));
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
// The gate CLI prints a summary, then (without --output) the full report JSON.
|
|
783
|
+
// Returns that object when the output carries a parseable one, else null.
|
|
784
|
+
function embeddedReport(output) {
|
|
785
|
+
const lines = String(output || '').split(/\r?\n/);
|
|
786
|
+
const start = lines.findIndex(line => line.startsWith('{'));
|
|
787
|
+
if (start < 0) return null;
|
|
788
|
+
try {
|
|
789
|
+
const value = JSON.parse(lines.slice(start).join('\n'));
|
|
790
|
+
return isObject(value) ? value : null;
|
|
791
|
+
} catch {
|
|
792
|
+
return null;
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
// Workspace checks: the evidence the command names must be retained, valid and
|
|
797
|
+
// re-evaluate to PASS, and any report the gate wrote or printed must equal the
|
|
798
|
+
// fresh re-evaluation. Definitive failures here win over the observed outcome.
|
|
799
|
+
function checkRetainedGateArtifacts(call, args, ws) {
|
|
800
|
+
const fail = reason => ({ pass: false, status: 'fail', reason });
|
|
801
|
+
const evidencePath = resolveInWorkspace(ws, args.evidence);
|
|
802
|
+
if (!evidencePath) return fail(`evidence file ${args.evidence} is outside the workspace`);
|
|
803
|
+
if (!fs.existsSync(evidencePath)) return fail(`evidence file ${args.evidence} was not retained in the workspace`);
|
|
804
|
+
let evidence;
|
|
805
|
+
try { evidence = JSON.parse(fs.readFileSync(evidencePath, 'utf8')); }
|
|
806
|
+
catch (error) { return fail(`evidence file ${args.evidence} is not valid JSON (${error.message})`); }
|
|
807
|
+
const { evaluateEvidence } = require(path.join(ROOT, 'tdd', 'scripts', 'quality-gate.js'));
|
|
808
|
+
const report = evaluateEvidence(evidence);
|
|
809
|
+
if (report.result !== 'PASS') {
|
|
810
|
+
const failed = Object.entries(report.mandatoryGates || {}).filter(([, ok]) => !ok).map(([name]) => name);
|
|
811
|
+
return fail(`independent re-evaluation of ${args.evidence} is ${report.result} (gate would exit 1)${failed.length ? `; failed gates: ${failed.join(', ')}` : ''}`);
|
|
812
|
+
}
|
|
813
|
+
// Compare through JSON so the fresh report has the same shape as a retained one.
|
|
814
|
+
const expected = JSON.parse(JSON.stringify(report));
|
|
815
|
+
if (args.output) {
|
|
816
|
+
const outputPath = resolveInWorkspace(ws, args.output);
|
|
817
|
+
if (!outputPath || !fs.existsSync(outputPath)) return fail(`quality report ${args.output} was not retained in the workspace`);
|
|
818
|
+
let retained;
|
|
819
|
+
try { retained = JSON.parse(fs.readFileSync(outputPath, 'utf8')); }
|
|
820
|
+
catch (error) { return fail(`quality report ${args.output} is not valid JSON (${error.message})`); }
|
|
821
|
+
if (!isObject(retained)) return fail(`retained report ${args.output} is not a JSON object`);
|
|
822
|
+
const { required } = require(path.join(ROOT, 'tdd', 'schemas', 'quality-report.schema.json'));
|
|
823
|
+
const missing = required.filter(key => !Object.prototype.hasOwnProperty.call(retained, key));
|
|
824
|
+
if (missing.length) return fail(`retained report ${args.output} is missing required keys: ${missing.join(', ')}`);
|
|
825
|
+
// The retained file must be the full report, not a marker.
|
|
826
|
+
const differing = firstDifferingKey(retained, expected);
|
|
827
|
+
if (differing !== undefined) return fail(`retained report ${args.output} differs from re-evaluation at "${differing}"`);
|
|
828
|
+
}
|
|
829
|
+
const printed = embeddedReport(call.output);
|
|
830
|
+
if (printed) {
|
|
831
|
+
const differing = firstDifferingKey(printed, expected);
|
|
832
|
+
if (differing !== undefined) return fail(`gate output report differs from re-evaluation at "${differing}"`);
|
|
833
|
+
}
|
|
834
|
+
return { pass: true, status: 'pass', reason: `quality gate re-evaluated ${args.evidence}: PASS` };
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
function verifyQualityGateRun(call, ws) {
|
|
838
|
+
const fail = reason => ({ pass: false, status: 'fail', reason });
|
|
839
|
+
const command = call && call.input && typeof call.input === 'object' ? call.input.command : undefined;
|
|
840
|
+
const args = qualityGateArgs(command);
|
|
841
|
+
if (!args) return fail('matched command is not a TDD quality-gate invocation');
|
|
842
|
+
if (args.script === 'test:tdd-quality') return fail('test:tdd-quality runs the repository self-test, not the gate on task evidence');
|
|
843
|
+
if (!args.evidence) return fail('quality gate was run without an evidence file, so no auditable report exists');
|
|
844
|
+
const artifacts = checkRetainedGateArtifacts(call, args, ws);
|
|
845
|
+
if (!artifacts.pass) return artifacts;
|
|
846
|
+
const outcome = observedGateOutcome(call);
|
|
847
|
+
if (outcome.status === 'fail') return fail(`quality gate command failed: ${outcome.reason}`);
|
|
848
|
+
if (outcome.status === 'inconclusive') return { pass: false, status: 'inconclusive', reason: outcome.reason };
|
|
849
|
+
return artifacts;
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
// The last matching attempt is the one that counts: an agent may fix evidence and
|
|
853
|
+
// re-run. Attempts are graded in transcript order, including failed, denied and
|
|
854
|
+
// unresolved ones, so an earlier PASS cannot satisfy the gate once a later run
|
|
855
|
+
// did not complete. A completed last run is re-verified from retained evidence.
|
|
856
|
+
function gradeQualityGate(expectation, parsed, ws) {
|
|
857
|
+
const attempted = gradeExecutionEvidence({ ...expectation, type: 'tool_attempted' }, parsed);
|
|
858
|
+
if (attempted.status === 'inconclusive') return attempted;
|
|
859
|
+
if (!attempted.pass) return { pass: false, status: 'fail', reason: `quality gate not executed: ${attempted.reason}` };
|
|
860
|
+
const last = attempted.observed[attempted.observed.length - 1];
|
|
861
|
+
if (last.denied === true || last.status === 'denied') {
|
|
862
|
+
return { pass: false, status: 'fail', reason: `the last quality gate run (${last.id}) was denied; an earlier run cannot satisfy the gate` };
|
|
863
|
+
}
|
|
864
|
+
if (last.status === 'failed') {
|
|
865
|
+
return { pass: false, status: 'fail', reason: `the last quality gate run (${last.id}) failed; an earlier run cannot satisfy the gate` };
|
|
866
|
+
}
|
|
867
|
+
if (last.status === 'completed') {
|
|
868
|
+
const verdict = verifyQualityGateRun(last, ws);
|
|
869
|
+
return { pass: verdict.pass, status: verdict.status, reason: verdict.reason };
|
|
870
|
+
}
|
|
871
|
+
return { pass: false, status: 'inconclusive', reason: `the last quality gate run (${last.id}) has no observable result` };
|
|
872
|
+
}
|
|
873
|
+
|
|
647
874
|
function grade(c, ws, transcriptPath, engine = 'auto') {
|
|
648
875
|
const parsed = parseTranscriptFile(transcriptPath, engine);
|
|
649
876
|
const ordered = parseTranscriptEvents(transcriptPath);
|
|
@@ -664,6 +891,8 @@ function grade(c, ws, transcriptPath, engine = 'auto') {
|
|
|
664
891
|
status = 'fail';
|
|
665
892
|
reason = 'expected a completed edit before the matching tool execution';
|
|
666
893
|
}
|
|
894
|
+
} else if (e.type === 'tdd_quality_gate') {
|
|
895
|
+
({ pass, status, reason } = gradeQualityGate(e, parsed, ws));
|
|
667
896
|
} else if (e.type === 'trace_contains' || e.type === 'trace_not_contains') {
|
|
668
897
|
if (e.type === 'trace_contains' && isExecutionLikeTraceValue(e.value)) {
|
|
669
898
|
status = 'inconclusive';
|
|
@@ -675,6 +904,23 @@ function grade(c, ws, transcriptPath, engine = 'auto') {
|
|
|
675
904
|
pass = e.type === 'trace_contains' ? trace.includes(e.value) : !trace.includes(e.value);
|
|
676
905
|
status = pass ? 'pass' : 'fail';
|
|
677
906
|
}
|
|
907
|
+
} else if (e.type === 'final_output_json_only') {
|
|
908
|
+
if (parsed.parseStatus !== 'parsed') {
|
|
909
|
+
status = 'inconclusive';
|
|
910
|
+
reason = parsed.parseError || 'transcript parser did not produce a complete structured trace';
|
|
911
|
+
} else {
|
|
912
|
+
// Neither join can hide prose: prose stays outside the JSON either way.
|
|
913
|
+
// Exact concatenation covers transport chunks of one JSON message;
|
|
914
|
+
// newline join covers separate complete blocks such as a fenced JSON
|
|
915
|
+
// block followed by a separate `<!-- harness-label ... -->` part.
|
|
916
|
+
// PASS if any candidate passes; otherwise report the exact-text reason.
|
|
917
|
+
const verdicts = finalAssistantTextCandidates(ordered.events)
|
|
918
|
+
.map(candidate => checkJsonOnlyOutput(candidate, e.required_keys || []));
|
|
919
|
+
const verdict = verdicts.find(v => v.pass) || verdicts[0];
|
|
920
|
+
pass = verdict.pass;
|
|
921
|
+
status = pass ? 'pass' : 'fail';
|
|
922
|
+
reason = verdict.reason;
|
|
923
|
+
}
|
|
678
924
|
} else if (e.type === 'file_contains') {
|
|
679
925
|
pass = fs.readFileSync(path.join(ws, e.path), 'utf8').includes(e.value);
|
|
680
926
|
status = pass ? 'pass' : 'fail';
|
|
@@ -998,7 +1244,12 @@ module.exports = {
|
|
|
998
1244
|
extractAgentEvents,
|
|
999
1245
|
extractAgentTrace,
|
|
1000
1246
|
extractSessionMetadata,
|
|
1247
|
+
checkJsonOnlyOutput,
|
|
1248
|
+
finalAssistantText,
|
|
1249
|
+
finalAssistantTextCandidates,
|
|
1001
1250
|
grade,
|
|
1251
|
+
GATE_COMMAND_ERROR_RE,
|
|
1252
|
+
qualityGateArgs,
|
|
1002
1253
|
parseTranscriptEvents,
|
|
1003
1254
|
pairVerdict,
|
|
1004
1255
|
parseSimpleYaml,
|
|
@@ -28,6 +28,32 @@ function clone(value) {
|
|
|
28
28
|
return JSON.parse(JSON.stringify(value));
|
|
29
29
|
}
|
|
30
30
|
|
|
31
|
+
// Observable outcome of a tool result: the text the tool printed, whether the
|
|
32
|
+
// host flagged it as an error, and an exit code when the engine reports one.
|
|
33
|
+
// Claude tool_result blocks carry content and is_error; OpenCode carries
|
|
34
|
+
// state.output and state.metadata (merged into the result as `metadata`).
|
|
35
|
+
function normalizeOutput(content) {
|
|
36
|
+
if (content === null || content === undefined) return null;
|
|
37
|
+
if (typeof content === 'string') return content;
|
|
38
|
+
if (Array.isArray(content)) {
|
|
39
|
+
const texts = content
|
|
40
|
+
.map(block => {
|
|
41
|
+
if (typeof block === 'string') return block;
|
|
42
|
+
return isObject(block) && typeof block.text === 'string' ? block.text : null;
|
|
43
|
+
})
|
|
44
|
+
.filter(text => text !== null);
|
|
45
|
+
return texts.length ? texts.join('\n') : null;
|
|
46
|
+
}
|
|
47
|
+
if (isObject(content)) return JSON.stringify(content);
|
|
48
|
+
return String(content);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function exitCodeOf(result) {
|
|
52
|
+
const metadata = isObject(result.metadata) ? result.metadata : {};
|
|
53
|
+
const value = [result.exit_code, result.exitCode, metadata.exit].find(item => typeof item === 'number');
|
|
54
|
+
return value;
|
|
55
|
+
}
|
|
56
|
+
|
|
31
57
|
function makeMetadata() {
|
|
32
58
|
return {
|
|
33
59
|
cost: null,
|
|
@@ -144,6 +170,9 @@ function ensureCall(context, details = {}) {
|
|
|
144
170
|
denied: false,
|
|
145
171
|
status: 'attempted',
|
|
146
172
|
result_count: 0,
|
|
173
|
+
output: null,
|
|
174
|
+
is_error: null,
|
|
175
|
+
exit_code: null,
|
|
147
176
|
explicitId: Boolean(suppliedId),
|
|
148
177
|
anonymousKey: suppliedId ? null : anonymousKey,
|
|
149
178
|
};
|
|
@@ -194,6 +223,13 @@ function addToolResult(context, details = {}, result = {}, forcedStatus) {
|
|
|
194
223
|
call.result_count += 1;
|
|
195
224
|
}
|
|
196
225
|
applyStatus(call, status);
|
|
226
|
+
// Keep the latest observable outcome. A result that does not report a field
|
|
227
|
+
// leaves the earlier value alone rather than erasing it.
|
|
228
|
+
const output = normalizeOutput(content);
|
|
229
|
+
if (output !== null) call.output = output;
|
|
230
|
+
if (typeof result.is_error === 'boolean') call.is_error = result.is_error;
|
|
231
|
+
const exitCode = exitCodeOf(result);
|
|
232
|
+
if (exitCode !== undefined) call.exit_code = exitCode;
|
|
197
233
|
return call;
|
|
198
234
|
}
|
|
199
235
|
|
|
@@ -417,6 +453,9 @@ function publicCall(call) {
|
|
|
417
453
|
completed: call.completed,
|
|
418
454
|
denied: call.denied,
|
|
419
455
|
status: call.status,
|
|
456
|
+
output: call.output ?? null,
|
|
457
|
+
is_error: call.is_error ?? null,
|
|
458
|
+
exit_code: call.exit_code ?? null,
|
|
420
459
|
};
|
|
421
460
|
}
|
|
422
461
|
|
|
@@ -540,6 +579,10 @@ function targetMatches(call, target) {
|
|
|
540
579
|
if (target.id !== undefined && call.id !== target.id) return false;
|
|
541
580
|
if (target.name !== undefined && call.name !== target.name) return false;
|
|
542
581
|
if (target.command !== undefined && (!isObject(call.input) || !commandMatches(call.input.command, target.command))) return false;
|
|
582
|
+
// command_pattern lets one expectation accept several explicit command shapes
|
|
583
|
+
// (for example npm run tdd:quality or node tdd/scripts/quality-gate.js) with
|
|
584
|
+
// arbitrary evidence arguments; shell control operators are rejected.
|
|
585
|
+
if (target.command_pattern !== undefined && (!isObject(call.input) || typeof call.input.command !== 'string' || !new RegExp(target.command_pattern).test(call.input.command))) return false;
|
|
543
586
|
if (target.input_contains !== undefined && !stableJson(call.input).includes(String(target.input_contains))) return false;
|
|
544
587
|
return true;
|
|
545
588
|
}
|