harness-everything 0.41.3 → 0.42.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/benchmarks/results/h1-provider-independence-qualification/README.md +72 -0
- package/ci/mechanism-325-3b-h1-qualification.test.js +206 -0
- package/ci/mechanism-341-opencode-lane.test.js +465 -0
- package/hooks/scripts/lib/doc-reconciliation-pilot.js +40 -10
- package/hooks/scripts/lib/doc-reconciliation-provider.js +159 -11
- package/hooks/scripts/workflow-stop-gate.js +4 -1
- package/opencode-plugin/index.mjs +77 -5
- 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/hooks/scripts/lib/doc-reconciliation-pilot.js +40 -10
- package/plugins/harness-everything/hooks/scripts/lib/doc-reconciliation-provider.js +159 -11
- package/plugins/harness-everything/hooks/scripts/workflow-stop-gate.js +4 -1
- package/plugins/harness-everything/plugin.json +1 -1
- package/sbom.cdx.json +7 -7
|
@@ -0,0 +1,465 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const assert = require('node:assert/strict');
|
|
4
|
+
const crypto = require('node:crypto');
|
|
5
|
+
const fs = require('node:fs');
|
|
6
|
+
const os = require('node:os');
|
|
7
|
+
const path = require('node:path');
|
|
8
|
+
const { spawnSync } = require('node:child_process');
|
|
9
|
+
const { pathToFileURL } = require('node:url');
|
|
10
|
+
const { test } = require('node:test');
|
|
11
|
+
|
|
12
|
+
const ROOT = path.resolve(__dirname, '..');
|
|
13
|
+
const STOP_GATE = path.join(ROOT, 'hooks/scripts/workflow-stop-gate.js');
|
|
14
|
+
const PILOT = path.join(ROOT, 'hooks/scripts/lib/doc-reconciliation-pilot.js');
|
|
15
|
+
const savedEnv = Object.fromEntries(['HOME', 'HARNESS_STATE_HOME', 'HARNESS_WORKSPACE_ROOT', 'HARNESS_DOC_RECONCILIATION_PILOT'].map(key => [key, process.env[key]]));
|
|
16
|
+
|
|
17
|
+
function git(cwd, args) {
|
|
18
|
+
const env = { ...process.env, GIT_AUTHOR_NAME: 'Harness test', GIT_AUTHOR_EMAIL: 'harness@example.invalid', GIT_COMMITTER_NAME: 'Harness test', GIT_COMMITTER_EMAIL: 'harness@example.invalid' };
|
|
19
|
+
for (const key of Object.keys(env)) if (key.startsWith('GIT_') && !['GIT_AUTHOR_NAME', 'GIT_AUTHOR_EMAIL', 'GIT_COMMITTER_NAME', 'GIT_COMMITTER_EMAIL'].includes(key)) delete env[key];
|
|
20
|
+
return spawnSync('git', ['-C', cwd, ...args], { encoding: 'utf8', env });
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function runJson(script, cwd, input, env = {}) {
|
|
24
|
+
return spawnSync(process.execPath, [script], {
|
|
25
|
+
cwd: ROOT,
|
|
26
|
+
encoding: 'utf8',
|
|
27
|
+
input: JSON.stringify(input),
|
|
28
|
+
env: { ...process.env, ...env },
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function makeFixture() {
|
|
33
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'harness-341-opencode-workspace-'));
|
|
34
|
+
const stateHome = fs.mkdtempSync(path.join(os.tmpdir(), 'harness-341-opencode-state-'));
|
|
35
|
+
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'harness-341-opencode-home-'));
|
|
36
|
+
fs.mkdirSync(path.join(root, '.agents/harness-everything'), { recursive: true });
|
|
37
|
+
fs.mkdirSync(path.join(root, 'docs/adr'), { recursive: true });
|
|
38
|
+
fs.writeFileSync(path.join(root, '.agents/harness-everything/project-docs.json'), JSON.stringify({ governed: [{ path: 'docs/adr/**', role: 'adr' }], domains: ['GRPH'] }, null, 2) + '\n');
|
|
39
|
+
fs.writeFileSync(path.join(root, 'docs/adr/GRPH-001.md'), '# GRPH-001 Retry\n\nCheckout MUST retry once.\n');
|
|
40
|
+
fs.writeFileSync(path.join(root, 'package.json'), JSON.stringify({ name: 'fixture', version: '1.0.0', scripts: { test: 'node -e "process.exit(0)"' } }, null, 2));
|
|
41
|
+
const init = git(root, ['init', '-q']);
|
|
42
|
+
assert.equal(init.status, 0, init.stderr);
|
|
43
|
+
assert.equal(git(root, ['add', '-A']).status, 0);
|
|
44
|
+
assert.equal(git(root, ['-c', 'commit.gpgsign=false', 'commit', '-q', '-m', 'baseline']).status, 0);
|
|
45
|
+
fs.writeFileSync(path.join(root, 'docs/adr/GRPH-001.md'), '# GRPH-001 Retry\n\nCheckout MUST retry twice.\n');
|
|
46
|
+
process.env.HOME = home;
|
|
47
|
+
process.env.HARNESS_STATE_HOME = stateHome;
|
|
48
|
+
process.env.HARNESS_WORKSPACE_ROOT = root;
|
|
49
|
+
process.env.HARNESS_DOC_RECONCILIATION_PILOT = '1';
|
|
50
|
+
return {
|
|
51
|
+
root, stateHome, home,
|
|
52
|
+
cleanup: () => {
|
|
53
|
+
for (const directory of [root, stateHome, home]) fs.rmSync(directory, { recursive: true, force: true });
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function seedWorkflow(fixture, sessionId) {
|
|
59
|
+
const payload = { session_id: sessionId, cwd: fixture.root, host_id: 'opencode' };
|
|
60
|
+
const { getSessionDir } = require(path.join(ROOT, 'hooks/scripts/lib/harness-state.js'));
|
|
61
|
+
const sessionDir = getSessionDir(fixture.root, sessionId, payload);
|
|
62
|
+
const plan = { strategySelection: 'selected', tier: 'tier2', strategy: 'iterative-single' };
|
|
63
|
+
const context = {
|
|
64
|
+
root: fixture.root,
|
|
65
|
+
sessionId,
|
|
66
|
+
sessionDir,
|
|
67
|
+
file: path.join(sessionDir, 'workflow-run.json'),
|
|
68
|
+
workflow: {
|
|
69
|
+
schemaVersion: 1,
|
|
70
|
+
workflowId: `wf-${sessionId}`,
|
|
71
|
+
revision: 1,
|
|
72
|
+
sessionId,
|
|
73
|
+
tier: 'tier2',
|
|
74
|
+
strategy: 'iterative-single',
|
|
75
|
+
state: 'running',
|
|
76
|
+
workflowPlan: plan,
|
|
77
|
+
},
|
|
78
|
+
};
|
|
79
|
+
const obligations = require(path.join(ROOT, 'hooks/scripts/lib/workflow-obligations.js'));
|
|
80
|
+
obligations.initializePlanningContract(context, plan);
|
|
81
|
+
obligations.recordPlanning(context, JSON.stringify([
|
|
82
|
+
{ id: 'edit-doc', stepType: 'behavior-change', summary: 'Revise the governed retry requirement', acceptance: 'the normative requirement is updated' },
|
|
83
|
+
{ id: 'verify-doc', stepType: 'verification', summary: 'Verify the governed retry change', acceptance: 'the objective checks are recorded' },
|
|
84
|
+
]), plan, plan.strategy, 'fixture workflow for the OpenCode reconciliation path');
|
|
85
|
+
obligations.materializeExecutionObligations(context, plan);
|
|
86
|
+
require(PILOT).captureBaseline(context);
|
|
87
|
+
require(path.join(ROOT, 'hooks/scripts/lib/workflow-runtime.js')).saveWorkflow(context);
|
|
88
|
+
return { sessionDir, payload };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function writeEditState(sessionDir, values) {
|
|
92
|
+
fs.writeFileSync(path.join(sessionDir, 'edit-state.json'), JSON.stringify({
|
|
93
|
+
lastEditTime: null,
|
|
94
|
+
verificationPending: false,
|
|
95
|
+
editsSinceVerification: 0,
|
|
96
|
+
followUpPending: false,
|
|
97
|
+
followUpDeliveryPending: false,
|
|
98
|
+
followUpMessage: null,
|
|
99
|
+
lastFollowUpError: null,
|
|
100
|
+
sessionStart: Date.now(),
|
|
101
|
+
...values,
|
|
102
|
+
}));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
function createEventStream() {
|
|
106
|
+
const queued = [];
|
|
107
|
+
const waiters = [];
|
|
108
|
+
let last = null;
|
|
109
|
+
let closed = false;
|
|
110
|
+
return {
|
|
111
|
+
subscribe(signal) {
|
|
112
|
+
const iterator = {
|
|
113
|
+
next() {
|
|
114
|
+
if (last) { last.resolve(); last = null; }
|
|
115
|
+
if (queued.length) {
|
|
116
|
+
last = queued.shift();
|
|
117
|
+
return Promise.resolve({ value: last.value, done: false });
|
|
118
|
+
}
|
|
119
|
+
if (closed) return Promise.resolve({ done: true });
|
|
120
|
+
return new Promise(resolve => waiters.push(resolve));
|
|
121
|
+
},
|
|
122
|
+
return() { closed = true; return Promise.resolve({ done: true }); },
|
|
123
|
+
};
|
|
124
|
+
signal.addEventListener('abort', () => {
|
|
125
|
+
closed = true;
|
|
126
|
+
while (waiters.length) waiters.shift()({ done: true });
|
|
127
|
+
}, { once: true });
|
|
128
|
+
return { [Symbol.asyncIterator]: () => iterator };
|
|
129
|
+
},
|
|
130
|
+
async dispatch(value) {
|
|
131
|
+
await new Promise(resolve => {
|
|
132
|
+
const item = { value, resolve };
|
|
133
|
+
if (waiters.length) {
|
|
134
|
+
if (last) { last.resolve(); last = null; }
|
|
135
|
+
last = item;
|
|
136
|
+
waiters.shift()({ value, done: false });
|
|
137
|
+
} else queued.push(item);
|
|
138
|
+
});
|
|
139
|
+
},
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
async function startPlugin(workspace) {
|
|
144
|
+
const plugin = await import(pathToFileURL(path.join(ROOT, 'opencode-plugin/index.mjs')).href);
|
|
145
|
+
const registered = new Map();
|
|
146
|
+
const stream = createEventStream();
|
|
147
|
+
const dispose = await plugin.default.setup({
|
|
148
|
+
location: { directory: workspace },
|
|
149
|
+
tool: { hook: async (name, callback) => registered.set(name, callback) },
|
|
150
|
+
session: { prompt: async () => ({}) },
|
|
151
|
+
event: { subscribe: ({ signal }) => stream.subscribe(signal) },
|
|
152
|
+
});
|
|
153
|
+
return { registered, stream, dispose };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function canonicalJson(value) {
|
|
157
|
+
if (Array.isArray(value)) return value.map(canonicalJson);
|
|
158
|
+
if (value && typeof value === 'object') return Object.fromEntries(Object.keys(value).sort().map(key => [key, canonicalJson(value[key])]));
|
|
159
|
+
return value;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function restoreEnv() {
|
|
163
|
+
for (const [key, value] of Object.entries(savedEnv)) {
|
|
164
|
+
if (value === undefined) delete process.env[key];
|
|
165
|
+
else process.env[key] = value;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
test('existing Stop consumer writes a refused report without qualification', () => {
|
|
170
|
+
const fixture = makeFixture();
|
|
171
|
+
try {
|
|
172
|
+
const control = seedWorkflow(fixture, 'stop-positive-control');
|
|
173
|
+
const result = runJson(STOP_GATE, fixture.root, control.payload);
|
|
174
|
+
const reportPath = path.join(control.sessionDir, 'doc-reconciliation-pilot.json');
|
|
175
|
+
const report = fs.existsSync(reportPath) ? JSON.parse(fs.readFileSync(reportPath, 'utf8')) : null;
|
|
176
|
+
assert.equal(result.status, 0, result.stderr);
|
|
177
|
+
assert.equal(report?.pilotStatus, 'unresolved');
|
|
178
|
+
assert.equal(report?.pilotCredit, false);
|
|
179
|
+
assert.ok(report?.diagnostics?.length, 'existing consumer must retain a refusal diagnostic');
|
|
180
|
+
} finally {
|
|
181
|
+
fixture.cleanup();
|
|
182
|
+
restoreEnv();
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
test('OpenCode capture → idle Stop whole path is refused without live qualification', async () => {
|
|
187
|
+
const fixture = makeFixture();
|
|
188
|
+
const findings = [];
|
|
189
|
+
let dispose;
|
|
190
|
+
let originalAssess;
|
|
191
|
+
try {
|
|
192
|
+
const sessionId = 'native-v2-whole-path';
|
|
193
|
+
const { sessionDir } = seedWorkflow(fixture, sessionId);
|
|
194
|
+
const pilot = require(PILOT);
|
|
195
|
+
originalAssess = pilot.assess;
|
|
196
|
+
const assessmentSessions = [];
|
|
197
|
+
pilot.assess = function (context, ...args) {
|
|
198
|
+
assessmentSessions.push(context?.sessionId);
|
|
199
|
+
return originalAssess.call(this, context, ...args);
|
|
200
|
+
};
|
|
201
|
+
|
|
202
|
+
const plugin = await startPlugin(fixture.root);
|
|
203
|
+
dispose = plugin.dispose;
|
|
204
|
+
const nativeResult = { output: 'private-result-body', metadata: { exit: 0 } };
|
|
205
|
+
await plugin.registered.get('execute.after')({
|
|
206
|
+
tool: 'edit', sessionID: sessionId, id: 'call-1', messageID: 'message-1',
|
|
207
|
+
status: 'completed', result: nativeResult,
|
|
208
|
+
input: { filePath: path.join(fixture.root, 'docs/adr/GRPH-001.md') },
|
|
209
|
+
});
|
|
210
|
+
await plugin.registered.get('execute.after')({
|
|
211
|
+
tool: 'bash', sessionID: sessionId, id: 'call-2', messageID: 'message-2',
|
|
212
|
+
status: 'completed', error: { name: 'NativeError', message: 'native command error', code: 'NATIVE_FAILURE' },
|
|
213
|
+
input: { command: 'doc-check' },
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
const journal = path.join(sessionDir, 'opencode-captures.jsonl');
|
|
217
|
+
let records = [];
|
|
218
|
+
if (fs.existsSync(journal)) {
|
|
219
|
+
const lines = fs.readFileSync(journal, 'utf8').trim().split(/\r?\n/);
|
|
220
|
+
records = lines.map(line => JSON.parse(line));
|
|
221
|
+
}
|
|
222
|
+
const canonical = JSON.stringify(canonicalJson(nativeResult));
|
|
223
|
+
const record = records[0];
|
|
224
|
+
const errorRecord = records[1];
|
|
225
|
+
if (records.length !== 2 || !record || record.hostId !== 'opencode' || record.session_id !== sessionId || record.call_id !== 'call-1' ||
|
|
226
|
+
record.message_id !== 'message-1' || record.status !== 'completed' || record.exit !== 0 ||
|
|
227
|
+
record.cwd !== undefined || record.result_digest !== crypto.createHash('sha256').update(canonical).digest('hex') ||
|
|
228
|
+
record.result_bytes !== Buffer.byteLength(canonical) || fs.readFileSync(journal, 'utf8').includes('private-result-body')) {
|
|
229
|
+
findings.push(`native result was not retained as one host-labelled digest-only record: ${JSON.stringify(record)}`);
|
|
230
|
+
}
|
|
231
|
+
if (!errorRecord || errorRecord.hostId !== 'opencode' || errorRecord.call_id !== 'call-2' ||
|
|
232
|
+
errorRecord.error?.name !== 'NativeError' || errorRecord.error?.message !== 'native command error' ||
|
|
233
|
+
errorRecord.error?.code !== 'NATIVE_FAILURE' || errorRecord.exit === 0) {
|
|
234
|
+
findings.push(`native error was not preserved as failure without fabricated exit zero: ${JSON.stringify(errorRecord)}`);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
await plugin.stream.dispatch({ type: 'session.idle', sessionID: sessionId });
|
|
238
|
+
const reportPath = path.join(sessionDir, 'doc-reconciliation-pilot.json');
|
|
239
|
+
const report = fs.existsSync(reportPath) ? JSON.parse(fs.readFileSync(reportPath, 'utf8')) : null;
|
|
240
|
+
if (assessmentSessions.filter(id => id === sessionId).length !== 1) {
|
|
241
|
+
findings.push(`idle Stop dispatch count=${assessmentSessions.filter(id => id === sessionId).length}, expected exactly one`);
|
|
242
|
+
}
|
|
243
|
+
if (!report || report.pilotCredit !== false || report.pilotStatus !== 'unresolved' || !report.diagnostics?.length) {
|
|
244
|
+
findings.push(`idle did not write the actual refused/unavailable report: ${JSON.stringify(report)}`);
|
|
245
|
+
}
|
|
246
|
+
assert.deepEqual(findings, []);
|
|
247
|
+
} finally {
|
|
248
|
+
if (dispose) dispose();
|
|
249
|
+
if (originalAssess) require(PILOT).assess = originalAssess;
|
|
250
|
+
fixture.cleanup();
|
|
251
|
+
restoreEnv();
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
test('session idle dispatches the real consumer once across every existing early return', async () => {
|
|
256
|
+
const fixture = makeFixture();
|
|
257
|
+
let dispose;
|
|
258
|
+
const findings = [];
|
|
259
|
+
const pilot = require(PILOT);
|
|
260
|
+
const originalAssess = pilot.assess;
|
|
261
|
+
const assessmentSessions = [];
|
|
262
|
+
pilot.assess = function (context, ...args) {
|
|
263
|
+
assessmentSessions.push(context?.sessionId);
|
|
264
|
+
return originalAssess.call(this, context, ...args);
|
|
265
|
+
};
|
|
266
|
+
try {
|
|
267
|
+
const plugin = await startPlugin(fixture.root);
|
|
268
|
+
dispose = plugin.dispose;
|
|
269
|
+
const cases = [
|
|
270
|
+
['follow-up-pending', { followUpPending: true }],
|
|
271
|
+
['follow-up-delivery-pending', { followUpDeliveryPending: true, followUpMessage: 'deliver once' }],
|
|
272
|
+
['no-verification-pending', { verificationPending: false, editsSinceVerification: 0 }],
|
|
273
|
+
['verification-succeeds', { verificationPending: true, editsSinceVerification: 1 }],
|
|
274
|
+
];
|
|
275
|
+
for (const [suffix, state] of cases) {
|
|
276
|
+
const sessionId = `idle-${suffix}`;
|
|
277
|
+
const { sessionDir } = seedWorkflow(fixture, sessionId);
|
|
278
|
+
writeEditState(sessionDir, state);
|
|
279
|
+
await plugin.stream.dispatch({ type: 'session.idle', sessionID: sessionId });
|
|
280
|
+
const reportPath = path.join(sessionDir, 'doc-reconciliation-pilot.json');
|
|
281
|
+
const report = fs.existsSync(reportPath) ? JSON.parse(fs.readFileSync(reportPath, 'utf8')) : null;
|
|
282
|
+
if (assessmentSessions.filter(id => id === sessionId).length !== 1) {
|
|
283
|
+
findings.push(`${suffix}: consumer assessment count=${assessmentSessions.filter(id => id === sessionId).length}, expected exactly one`);
|
|
284
|
+
}
|
|
285
|
+
if (!report || report.pilotCredit !== false || report.pilotStatus !== 'unresolved') {
|
|
286
|
+
findings.push(`${suffix}: expected one refused/unavailable report, received ${JSON.stringify(report)}`);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
const cleanupSession = 'idle-no-verification-pending';
|
|
290
|
+
await plugin.stream.dispatch({ type: 'session.idle', sessionID: cleanupSession });
|
|
291
|
+
if (assessmentSessions.filter(id => id === cleanupSession).length !== 2) {
|
|
292
|
+
findings.push(`finally did not release the per-session idle guard for a second event: ${assessmentSessions.filter(id => id === cleanupSession).length} assessments`);
|
|
293
|
+
}
|
|
294
|
+
assert.deepEqual(findings, []);
|
|
295
|
+
} finally {
|
|
296
|
+
if (dispose) dispose();
|
|
297
|
+
pilot.assess = originalAssess;
|
|
298
|
+
fixture.cleanup();
|
|
299
|
+
restoreEnv();
|
|
300
|
+
}
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
test('V1/no-status, pilot-OFF, no-workflow, and Claude host controls stay fail-closed', async () => {
|
|
304
|
+
const fixture = makeFixture();
|
|
305
|
+
let dispose;
|
|
306
|
+
try {
|
|
307
|
+
const plugin = await startPlugin(fixture.root);
|
|
308
|
+
dispose = plugin.dispose;
|
|
309
|
+
|
|
310
|
+
const v1Session = 'v1-no-status';
|
|
311
|
+
const { sessionDir: v1Dir } = seedWorkflow(fixture, v1Session);
|
|
312
|
+
await plugin.registered.get('execute.after')({
|
|
313
|
+
tool: 'edit', sessionID: v1Session, id: 'v1-call', input: { filePath: path.join(fixture.root, 'docs/adr/GRPH-001.md') },
|
|
314
|
+
});
|
|
315
|
+
assert.equal(fs.existsSync(path.join(v1Dir, 'opencode-captures.jsonl')), false, 'V1/no-status output must not create a native capture');
|
|
316
|
+
|
|
317
|
+
const noWorkflow = 'no-workflow';
|
|
318
|
+
const { getSessionDir } = require(path.join(ROOT, 'hooks/scripts/lib/harness-state.js'));
|
|
319
|
+
const noWorkflowDir = getSessionDir(fixture.root, noWorkflow, { session_id: noWorkflow, cwd: fixture.root, host_id: 'opencode' });
|
|
320
|
+
await plugin.stream.dispatch({ type: 'session.idle', sessionID: noWorkflow });
|
|
321
|
+
assert.equal(fs.existsSync(path.join(noWorkflowDir, 'doc-reconciliation-pilot.json')), false, 'no workflow must not write a pilot report');
|
|
322
|
+
|
|
323
|
+
const offSession = 'pilot-off';
|
|
324
|
+
const { sessionDir: offDir } = seedWorkflow(fixture, offSession);
|
|
325
|
+
process.env.HARNESS_DOC_RECONCILIATION_PILOT = '0';
|
|
326
|
+
await plugin.stream.dispatch({ type: 'session.idle', sessionID: offSession });
|
|
327
|
+
assert.equal(fs.existsSync(path.join(offDir, 'doc-reconciliation-pilot.json')), false, 'pilot OFF must not write a reconciliation report');
|
|
328
|
+
|
|
329
|
+
const { createClaudeOriginProvider } = require(path.join(ROOT, 'hooks/scripts/lib/doc-reconciliation-provider.js'));
|
|
330
|
+
const claudeProvider = createClaudeOriginProvider({ hostId: 'opencode', sessionId: 'native-host-control' });
|
|
331
|
+
const claudeContext = claudeProvider.resolveContext({ workspaceRoot: fixture.root });
|
|
332
|
+
assert.equal(claudeContext.status, 'unavailable', 'OpenCode must not enter through Claude provider semantics');
|
|
333
|
+
assert.ok(claudeContext.diagnostics.some(item => item.code === 'unsupported-host'));
|
|
334
|
+
} finally {
|
|
335
|
+
if (dispose) dispose();
|
|
336
|
+
fixture.cleanup();
|
|
337
|
+
restoreEnv();
|
|
338
|
+
}
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
const OPENCODE_SESSION = 'provider-session';
|
|
342
|
+
const OPENCODE_QUALIFICATION = {
|
|
343
|
+
source: 'fake-test-fixture', faked: true, toolUseIdPopulated: true, agentIdPopulated: true,
|
|
344
|
+
};
|
|
345
|
+
|
|
346
|
+
function opencodeCapture(overrides = {}) {
|
|
347
|
+
return {
|
|
348
|
+
schema: 'opencode-capture-record/1', hostId: 'opencode', session_id: OPENCODE_SESSION,
|
|
349
|
+
call_id: 'provider-call', tool: 'bash', command: 'npm test', cwd: ROOT,
|
|
350
|
+
status: 'completed', exit: 0, result_digest: 'a'.repeat(64), result_bytes: 1,
|
|
351
|
+
observedIdentity: { workflowId: 'provider-workflow', workflowRevision: 3, baselinePin: null },
|
|
352
|
+
...overrides,
|
|
353
|
+
};
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
function opencodeProvider(captures, overrides = {}) {
|
|
357
|
+
const { createOpencodeOriginProvider } = require(path.join(ROOT, 'hooks/scripts/lib/doc-reconciliation-provider.js'));
|
|
358
|
+
return createOpencodeOriginProvider({
|
|
359
|
+
hostId: 'opencode', sessionId: OPENCODE_SESSION, workspaceRoot: ROOT,
|
|
360
|
+
workflowId: 'provider-workflow', workflowRevision: 3, taskId: 'provider-task',
|
|
361
|
+
principals: { author: 'provider-author', executors: ['provider-executor'] },
|
|
362
|
+
qualification: OPENCODE_QUALIFICATION, captures,
|
|
363
|
+
...overrides,
|
|
364
|
+
});
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
function diagnosticCode(result) { return result.diagnostics?.[0]?.code; }
|
|
368
|
+
|
|
369
|
+
test('fake-DI OpenCode origin accepts the complete native execution record shape', () => {
|
|
370
|
+
const provider = opencodeProvider([opencodeCapture({ unrecognizedPrivateField: 'must-not-leak' })]);
|
|
371
|
+
const context = provider.resolveContext({ workspaceRoot: ROOT });
|
|
372
|
+
const result = provider.resolveEvent({
|
|
373
|
+
reference: 'provider-call',
|
|
374
|
+
identity: { workspaceId: 'ws-provider', sessionId: OPENCODE_SESSION, workflowId: 'provider-workflow', workflowRevision: 3 },
|
|
375
|
+
});
|
|
376
|
+
assert.equal(context.status, 'accepted');
|
|
377
|
+
assert.match(context.originRef, /^origin:\/\/opencode\//);
|
|
378
|
+
assert.equal(context.context.activeHost, 'opencode');
|
|
379
|
+
assert.equal(result.status, 'accepted');
|
|
380
|
+
assert.equal(result.originRef, context.originRef);
|
|
381
|
+
assert.equal(result.event.originRef, context.originRef);
|
|
382
|
+
assert.deepEqual(Object.keys(result.event).sort(), [
|
|
383
|
+
'argv', 'artifactDigests', 'captured', 'capability', 'command', 'cwd', 'endIdentity', 'eventId',
|
|
384
|
+
'exitCode', 'identity', 'kind', 'originRef', 'paths', 'principalId', 'producerId', 'provenance', 'resultRef',
|
|
385
|
+
'signal', 'startIdentity', 'toolCorrelationId',
|
|
386
|
+
].sort());
|
|
387
|
+
assert.equal(result.event.producerId, 'opencode-origin-provider');
|
|
388
|
+
assert.equal(result.event.capability, 'shell-command');
|
|
389
|
+
assert.equal(result.event.kind, 'execution');
|
|
390
|
+
assert.equal(result.event.exitCode, 0);
|
|
391
|
+
assert.equal(result.event.cwd, ROOT);
|
|
392
|
+
assert.equal(result.event.toolCorrelationId, 'provider-call');
|
|
393
|
+
assert.equal(result.event.captured.responseDigest, 'a'.repeat(64));
|
|
394
|
+
assert.deepEqual(result.correlation, { toolCorrelationId: 'provider-call', resultRef: result.event.resultRef });
|
|
395
|
+
|
|
396
|
+
const emptyResultDigest = crypto.createHash('sha256').update('').digest('hex');
|
|
397
|
+
const minimum = opencodeProvider([opencodeCapture({ call_id: 'empty-result', result_digest: emptyResultDigest, result_bytes: 0 })])
|
|
398
|
+
.resolveEvent({ reference: 'empty-result', identity: { workflowId: 'provider-workflow' } });
|
|
399
|
+
assert.equal(minimum.status, 'accepted', 'zero result bytes are the valid minimum boundary');
|
|
400
|
+
assert.equal(minimum.event.captured.responseDigest, emptyResultDigest);
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
test('production OpenCode qualification remains unmet with otherwise valid origin input', () => {
|
|
404
|
+
const provider = opencodeProvider([opencodeCapture()], { qualification: undefined });
|
|
405
|
+
const context = provider.resolveContext({ workspaceRoot: ROOT });
|
|
406
|
+
const result = provider.resolveEvent({ reference: 'provider-call', identity: { workflowId: 'provider-workflow' } });
|
|
407
|
+
assert.equal(context.status, 'unavailable');
|
|
408
|
+
assert.equal(diagnosticCode(context), 'live-gate-unmet');
|
|
409
|
+
assert.equal(result.status, 'unavailable');
|
|
410
|
+
assert.equal(diagnosticCode(result), 'live-gate-unmet');
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
test('OpenCode provider rejects native error, incomplete outcome, and missing cwd', () => {
|
|
414
|
+
const cases = [
|
|
415
|
+
['native-error', { error: { name: 'Error', message: 'failed' } }, 'failure-not-credited', 'provider-call'],
|
|
416
|
+
['missing-status', { status: undefined }, 'outcome-ambiguous', 'provider-call'],
|
|
417
|
+
['empty-status', { status: '' }, 'outcome-ambiguous', 'provider-call'],
|
|
418
|
+
['null-status', { status: null }, 'outcome-ambiguous', 'provider-call'],
|
|
419
|
+
['wrong-type-status', { status: 1 }, 'outcome-ambiguous', 'provider-call'],
|
|
420
|
+
['ambiguous-status', { status: 'running' }, 'outcome-ambiguous', 'provider-call'],
|
|
421
|
+
['missing-process-exit', { exit: undefined }, 'outcome-ambiguous', 'provider-call'],
|
|
422
|
+
['negative-result-bytes', { result_bytes: -1 }, 'stdout-withheld', 'provider-call'],
|
|
423
|
+
['missing-doc-check-cwd', { cwd: undefined, command: 'node harness-everything/scripts/doc-check.js' }, 'unregistered-command', 'provider-call#doc-lint'],
|
|
424
|
+
];
|
|
425
|
+
for (const [name, changes, expected, reference] of cases) {
|
|
426
|
+
const result = opencodeProvider([opencodeCapture(changes)]).resolveEvent({ reference, identity: { workflowId: 'provider-workflow' } });
|
|
427
|
+
assert.equal(result.status, 'rejected', `${name}: ${JSON.stringify(result)}`);
|
|
428
|
+
assert.equal(diagnosticCode(result), expected, name);
|
|
429
|
+
}
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
test('OpenCode provider rejects malformed references and unsupported host input', () => {
|
|
433
|
+
const invalidReference = opencodeProvider([opencodeCapture()]).resolveEvent({ reference: '' });
|
|
434
|
+
assert.equal(invalidReference.status, 'unavailable');
|
|
435
|
+
assert.equal(diagnosticCode(invalidReference), 'invalid-input');
|
|
436
|
+
|
|
437
|
+
const unsupportedContext = opencodeProvider([], { hostId: 'codex' }).resolveContext({ workspaceRoot: ROOT });
|
|
438
|
+
assert.equal(unsupportedContext.status, 'unavailable');
|
|
439
|
+
assert.equal(diagnosticCode(unsupportedContext), 'unsupported-host');
|
|
440
|
+
|
|
441
|
+
const unsupportedEvent = opencodeProvider([], { hostId: 'claude-code' }).resolveEvent({ reference: 'provider-call' });
|
|
442
|
+
assert.equal(unsupportedEvent.status, 'unavailable');
|
|
443
|
+
assert.equal(diagnosticCode(unsupportedEvent), 'unsupported-host');
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
test('OpenCode provider rejects stale, replayed, and Claude-origin records', () => {
|
|
447
|
+
const identity = { workflowId: 'provider-workflow', workflowRevision: 3 };
|
|
448
|
+
const stale = opencodeCapture({ observedIdentity: { workflowId: 'provider-workflow', workflowRevision: 2, baselinePin: null } });
|
|
449
|
+
assert.equal(diagnosticCode(opencodeProvider([stale]).resolveEvent({ reference: stale.call_id, identity })), 'identity-mismatch');
|
|
450
|
+
|
|
451
|
+
const first = opencodeCapture({ call_id: 'first-call' });
|
|
452
|
+
const replay = opencodeCapture({ call_id: 'replayed-call' });
|
|
453
|
+
const replayProvider = opencodeProvider([first, replay]);
|
|
454
|
+
assert.equal(diagnosticCode(replayProvider.resolveEvent({ reference: replay.call_id, identity })), 'replayed-record');
|
|
455
|
+
|
|
456
|
+
const claudeRecord = opencodeCapture({ hostId: 'claude-code' });
|
|
457
|
+
assert.equal(diagnosticCode(opencodeProvider([claudeRecord]).resolveEvent({ reference: claudeRecord.call_id, identity })), 'record-origin-mismatch');
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
test('OpenCode quality-review record is refused when reviewer equals the author', () => {
|
|
461
|
+
const record = opencodeCapture({ agent_id: 'provider-author' });
|
|
462
|
+
const result = opencodeProvider([record]).resolveEvent({ reference: `${record.call_id}#quality-review` });
|
|
463
|
+
assert.equal(result.status, 'rejected');
|
|
464
|
+
assert.equal(diagnosticCode(result), 'reviewer-not-independent');
|
|
465
|
+
});
|
|
@@ -114,9 +114,17 @@ function collect(context) {
|
|
|
114
114
|
// S2b credit chain (F4): produce the snapshot, accept the origin-provider events, and check them against the expected
|
|
115
115
|
// MUSTs. Credit only when the core marks the evidence eligible. Production has no qualification, so it stays withheld.
|
|
116
116
|
function creditChain(context, set, dependencies = {}) {
|
|
117
|
-
|
|
117
|
+
const hostId = activeHostFor(context);
|
|
118
|
+
const providerModule = require('./doc-reconciliation-provider');
|
|
119
|
+
const captureView = hostId === 'opencode' ? providerModule.readOpencodeCaptureView(context.sessionDir)
|
|
120
|
+
: hostId === 'claude-code' ? providerModule.readCaptureView(context.sessionDir)
|
|
121
|
+
: { records: [], tampered: false };
|
|
122
|
+
if (captureView.tampered) {
|
|
118
123
|
return { eligible: false, diagnostics: [{ code: 'journal-tampered', message: 'the capture journal no longer matches its immutable event records' }] };
|
|
119
124
|
}
|
|
125
|
+
if (!['claude-code', 'opencode'].includes(hostId)) {
|
|
126
|
+
return { eligible: false, diagnostics: [{ code: 'unsupported-host', message: `no document origin provider is registered for ${String(hostId)}` }] };
|
|
127
|
+
}
|
|
120
128
|
const { produceDocSnapshot } = require('./doc-reconciliation-producer');
|
|
121
129
|
const { acceptDocEvents } = require('./doc-reconciliation-events');
|
|
122
130
|
const { checkAcceptedDocEvidence } = require('./doc-reconciliation-core');
|
|
@@ -124,29 +132,38 @@ function creditChain(context, set, dependencies = {}) {
|
|
|
124
132
|
const requirements = Array.isArray(set?.requirements) ? set.requirements : [];
|
|
125
133
|
const taskId = `task-${requirements.map(item => item.id).join('+') || 'none'}`;
|
|
126
134
|
const sessionId = context.sessionId;
|
|
127
|
-
const { createClaudeOriginProvider, journalExecutors } =
|
|
128
|
-
const captures =
|
|
129
|
-
const
|
|
130
|
-
|
|
135
|
+
const { createClaudeOriginProvider, createOpencodeOriginProvider, journalExecutors } = providerModule;
|
|
136
|
+
const captures = captureView.records;
|
|
137
|
+
const principals = hostId === 'opencode'
|
|
138
|
+
? { author: sessionId, executors: [sessionId] }
|
|
139
|
+
: { author: sessionId, executors: journalExecutors(captures, sessionId) };
|
|
140
|
+
const createOriginProvider = hostId === 'opencode' ? createOpencodeOriginProvider : createClaudeOriginProvider;
|
|
141
|
+
const providerInput = {
|
|
142
|
+
hostId,
|
|
131
143
|
sessionId,
|
|
132
144
|
workspaceRoot: context.root,
|
|
133
145
|
workflowId: context.workflow.workflowId,
|
|
134
146
|
workflowRevision: context.workflow.revision,
|
|
135
147
|
taskId,
|
|
136
|
-
principals
|
|
148
|
+
principals,
|
|
137
149
|
baseline,
|
|
138
150
|
captures,
|
|
139
|
-
}
|
|
151
|
+
};
|
|
152
|
+
const originProvider = dependencies.originProvider || createOriginProvider(providerInput);
|
|
140
153
|
const produced = produceDocSnapshot({
|
|
141
154
|
workspaceRoot: context.root,
|
|
142
155
|
baseline,
|
|
143
156
|
workflow: { workflowId: context.workflow.workflowId, sessionId, workflowRevision: context.workflow.revision },
|
|
144
157
|
task: { taskId, requirements },
|
|
145
158
|
scope: { prdRoot: 'docs/prd/' },
|
|
146
|
-
activeHost:
|
|
159
|
+
activeHost: hostId,
|
|
147
160
|
prdRoot: 'docs/prd/',
|
|
148
161
|
}, { originProvider });
|
|
149
|
-
if (produced.state !== 'ready' || !produced.snapshot) return {
|
|
162
|
+
if (produced.state !== 'ready' || !produced.snapshot) return {
|
|
163
|
+
eligible: false,
|
|
164
|
+
diagnostics: produced.diagnostics || [],
|
|
165
|
+
unsatisfied: [],
|
|
166
|
+
};
|
|
150
167
|
const eventRefs = captureReferences(context);
|
|
151
168
|
const accepted = acceptDocEvents({ snapshot: produced.snapshot, eventRefs }, { originProvider });
|
|
152
169
|
const checked = checkAcceptedDocEvidence({ snapshot: produced.snapshot, accepted, mode: 'verification' });
|
|
@@ -181,6 +198,7 @@ function reviewRequired(set) {
|
|
|
181
198
|
// The review step is independent of credit qualification: the step records host-observed independence, while document
|
|
182
199
|
// credit itself still needs the validated qualification (assess).
|
|
183
200
|
function independentReviewAccepted(context) {
|
|
201
|
+
if (activeHostFor(context) !== 'claude-code') return false;
|
|
184
202
|
const { reviewRecordIndependent, journalExecutors } = require('./doc-reconciliation-provider');
|
|
185
203
|
const view = require('./doc-reconciliation-provider').readCaptureView(context.sessionDir);
|
|
186
204
|
if (view.tampered) return false;
|
|
@@ -196,10 +214,18 @@ function independentReviewAccepted(context) {
|
|
|
196
214
|
|
|
197
215
|
function recordOf(value) { return value !== null && typeof value === 'object' && !Array.isArray(value); }
|
|
198
216
|
|
|
217
|
+
function activeHostFor(context) {
|
|
218
|
+
return context.activeHost === undefined || context.activeHost === null ? 'claude-code' : context.activeHost;
|
|
219
|
+
}
|
|
220
|
+
|
|
199
221
|
// The session capture records are the only host-event source. A missing journal means no events; a journal that no longer
|
|
200
222
|
// equals its immutable event files is tampered and yields no events, so the chain withholds.
|
|
201
223
|
function readCaptures(context) {
|
|
202
|
-
|
|
224
|
+
const provider = require('./doc-reconciliation-provider');
|
|
225
|
+
const hostId = activeHostFor(context);
|
|
226
|
+
if (hostId === 'opencode') return provider.readOpencodeCaptureView(context.sessionDir).records;
|
|
227
|
+
if (hostId === 'claude-code') return provider.readCaptureView(context.sessionDir).records;
|
|
228
|
+
return [];
|
|
203
229
|
}
|
|
204
230
|
|
|
205
231
|
// Only explicit PostToolUse doc-check candidates are offered; ordinary Edit or Agent results are not (R3-01).
|
|
@@ -208,6 +234,10 @@ const docCheckCandidate = item => item.event === 'PostToolUse'
|
|
|
208
234
|
|
|
209
235
|
function captureReferences(context) {
|
|
210
236
|
const captures = readCaptures(context);
|
|
237
|
+
if (activeHostFor(context) === 'opencode') {
|
|
238
|
+
return captures.filter(item => typeof item?.call_id === 'string' && item.call_id.trim())
|
|
239
|
+
.flatMap(item => ['doc-lint', 'link-check'].map(capability => `${item.call_id}#${capability}`));
|
|
240
|
+
}
|
|
211
241
|
const candidates = captures
|
|
212
242
|
.filter(item => item && typeof item.ref === 'string' && (item.event === 'SubagentStop' || docCheckCandidate(item)))
|
|
213
243
|
.filter(item => item.event !== 'SubagentStop');
|