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.
@@ -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
- if (require('./doc-reconciliation-provider').readCaptureView(context.sessionDir).tampered) {
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 } = require('./doc-reconciliation-provider');
128
- const captures = readCaptures(context);
129
- const originProvider = dependencies.originProvider || createClaudeOriginProvider({
130
- hostId: 'claude-code',
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: { author: sessionId, executors: journalExecutors(captures, sessionId) },
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: 'claude-code',
159
+ activeHost: hostId,
147
160
  prdRoot: 'docs/prd/',
148
161
  }, { originProvider });
149
- if (produced.state !== 'ready' || !produced.snapshot) return { eligible: false, diagnostics: produced.diagnostics, unsatisfied: [] };
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
- return require('./doc-reconciliation-provider').readCaptureView(context.sessionDir).records;
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');