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.
@@ -15,8 +15,8 @@
15
15
  // qualification object ({ source: 'fake-test-fixture', faked: true }) is a
16
16
  // test-only construct for mechanism tests; production passes no
17
17
  // qualification and stays gated.
18
- // - Unsupported hosts (opencode/codex) never borrow the Claude exit mapping:
19
- // both seam methods report `unavailable` with diagnostics.
18
+ // - The Claude provider accepts only Claude. OpenCode has a separate
19
+ // fail-closed mapping and never borrows Claude's exit or reviewer rules.
20
20
  // - Exit mapping (owner decision row 1): for a given `tool_use_id`, a captured
21
21
  // `PostToolUse` with `interrupted === false` and no `PostToolUseFailure`
22
22
  // arrival maps to exit 0. This is a named mapping of the host's own
@@ -37,6 +37,10 @@ const path = require('path');
37
37
 
38
38
  const HOST_ID = 'claude-code';
39
39
  const CAPTURE_RECORD_VERSION = 'claude-capture-record/1';
40
+ const OPENCODE_HOST_ID = 'opencode';
41
+ const OPENCODE_CAPTURE_RECORD_VERSION = 'opencode-capture-record/1';
42
+ const OPENCODE_CAPTURE_FILE = 'opencode-captures.jsonl';
43
+ const OPENCODE_CAPTURE_LIMIT_BYTES = 4 * 1024 * 1024;
40
44
  const QUALIFICATION_EXPORT_SCHEMA = 'harness.doc-reconciliation.qualification.v1';
41
45
  const HEX64 = /^[0-9a-f]{64}$/;
42
46
  const PRE_CAPTURE_TOOLS = ['Bash', 'PowerShell', 'Task', 'Agent'];
@@ -522,10 +526,14 @@ function observedCorrelation(records) {
522
526
 
523
527
  // Validates the qualification export against the sanitized capture it names: schema, host, capture-record version, the
524
528
  // capture's digest, and every claimed kind, field and correlation. Returns null when valid, else the reason.
525
- function validateQualificationExport() {
529
+ function validateQualificationExport(expectedHost = HOST_ID) {
526
530
  const exported = JSON.parse(fs.readFileSync(path.join(__dirname, EXPORT_FILE), 'utf8'));
527
531
  if (!recordOf(exported) || exported.schema !== QUALIFICATION_EXPORT_SCHEMA) return 'qualification export schema is not recognized';
528
- if (exported.host !== HOST_ID || !textOf(exported.hostVersion)) return 'qualification export host or host version is missing';
532
+ if (!textOf(exported.hostVersion)) return 'qualification export host or host version is missing';
533
+ if (exported.host !== expectedHost) {
534
+ if (expectedHost === HOST_ID) return 'qualification export host or host version is missing';
535
+ return 'qualification export host does not match the selected provider';
536
+ }
529
537
  if (exported.captureRecordVersion !== CAPTURE_RECORD_VERSION) return 'qualification export capture record version is not supported';
530
538
  const source = recordOf(exported.sanitizedCapture) ? exported.sanitizedCapture : {};
531
539
  // The sanitized capture is a sibling of the export, so source and packaged copies resolve it the same way.
@@ -547,7 +555,7 @@ function validateQualificationExport() {
547
555
  return null;
548
556
  }
549
557
 
550
- function evaluateQualification(input) {
558
+ function evaluateQualification(input, expectedHost = HOST_ID) {
551
559
  if (recordOf(input) && input.qualification !== undefined && input.qualification !== null) {
552
560
  const supplied = input.qualification;
553
561
  // Test-only DI path: an explicitly fake qualification object. Anything else
@@ -567,7 +575,7 @@ function evaluateQualification(input) {
567
575
  // against the sanitized capture it names. Nothing is read from the environment, capture flags or ancestor directories.
568
576
  let reason = null;
569
577
  try {
570
- reason = validateQualificationExport();
578
+ reason = validateQualificationExport(expectedHost);
571
579
  } catch (error) {
572
580
  reason = `qualification evidence could not be read (${error.code || 'error'})`;
573
581
  }
@@ -582,11 +590,11 @@ function evaluateQualification(input) {
582
590
 
583
591
  // ---- Shared provider state ------------------------------------------------------
584
592
 
585
- function supportedHostCheck(input) {
586
- if (recordOf(input) && input.hostId === HOST_ID) return null;
593
+ function supportedHostCheck(input, expectedHost = HOST_ID) {
594
+ if ([HOST_ID, OPENCODE_HOST_ID].includes(expectedHost) && recordOf(input) && input.hostId === expectedHost) return null;
587
595
  const host = recordOf(input) && typeof input.hostId === 'string' ? input.hostId : 'unknown';
588
596
  return diagnostic('unsupported-host', {
589
- reason: `unsupported host ${host} has no supported capture implementation; OpenCode/Codex must not borrow the Claude exit mapping`,
597
+ reason: `unsupported host ${host} has no matching capture implementation; hosts must not borrow another host's exit mapping`,
590
598
  });
591
599
  }
592
600
 
@@ -892,6 +900,127 @@ const createClaudeOriginProvider = input => {
892
900
  return { resolveContext, resolveEvent };
893
901
  };
894
902
 
903
+ function opencodeContentSignature(cap) {
904
+ return captureJson({
905
+ hostId: cap.hostId,
906
+ tool: cap.tool === undefined ? null : cap.tool,
907
+ command: cap.command === undefined ? null : cap.command,
908
+ cwd: cap.cwd === undefined ? null : cap.cwd,
909
+ status: cap.status === undefined ? null : cap.status,
910
+ exit: cap.exit === undefined ? null : cap.exit,
911
+ resultDigest: cap.result_digest === undefined ? null : cap.result_digest,
912
+ error: cap.error === undefined ? null : cap.error,
913
+ });
914
+ }
915
+
916
+ const createOpencodeOriginProvider = input => {
917
+ const safeInput = recordOf(input) ? input : {};
918
+
919
+ function resolveContext(request) {
920
+ const hostFail = supportedHostCheck(safeInput, OPENCODE_HOST_ID);
921
+ if (hostFail) return { status: 'unavailable', diagnostics: [hostFail] };
922
+ const qualification = evaluateQualification(safeInput, OPENCODE_HOST_ID);
923
+ if (!qualification.open) return { status: 'unavailable', diagnostics: [qualification.fail] };
924
+ const workspaceRoot = textOf(safeInput.workspaceRoot) ? safeInput.workspaceRoot : (recordOf(request) && textOf(request.workspaceRoot) ? request.workspaceRoot : '');
925
+ const sessionId = textOf(safeInput.sessionId) ? safeInput.sessionId : '';
926
+ const workflowId = textOf(safeInput.workflowId) ? safeInput.workflowId : '';
927
+ const workflowRevision = Number.isInteger(safeInput.workflowRevision) ? safeInput.workflowRevision : null;
928
+ const taskId = textOf(safeInput.taskId) ? safeInput.taskId : '';
929
+ if (!workspaceRoot || !sessionId || !workflowId || workflowRevision === null || !taskId) {
930
+ return { status: 'unavailable', diagnostics: [diagnostic('invalid-input', { reason: 'OpenCode origin requires the recorded workspace, session, workflow, revision and task identity' })] };
931
+ }
932
+ const suppliedPrincipals = recordOf(safeInput.principals) ? safeInput.principals : {};
933
+ const author = textOf(suppliedPrincipals.author) ? suppliedPrincipals.author : sessionId;
934
+ const executors = Array.isArray(suppliedPrincipals.executors) ? suppliedPrincipals.executors.filter(textOf) : [sessionId];
935
+ const originRef = 'origin://opencode/' + digestText(captureJson({
936
+ hostId: OPENCODE_HOST_ID, sessionId, workspaceRoot, taskId, workflowId, workflowRevision,
937
+ author, executors, qualificationSource: qualification.source,
938
+ }));
939
+ return {
940
+ status: 'accepted', originRef,
941
+ context: {
942
+ workspaceId: 'ws-' + digestText(captureJson({ workspaceRoot })), sessionId, workflowId, workflowRevision, taskId,
943
+ principals: { author, executors: executors.slice() }, activeHost: OPENCODE_HOST_ID,
944
+ baselineReference: `retained-live-capture:opencode-341:${digestText(originRef)}`,
945
+ ...(recordOf(safeInput.baseline) && textOf(safeInput.baseline.commit) && textOf(safeInput.baseline.root)
946
+ ? { baseline: { commit: safeInput.baseline.commit, root: safeInput.baseline.root } } : {}),
947
+ workspaceRoot,
948
+ },
949
+ };
950
+ }
951
+
952
+ function resolveEvent(request) {
953
+ if (!recordOf(request) || !textOf(request.reference)) return unavailable('invalid-input', 'resolveEvent requires a nonempty event reference');
954
+ const hostFail = supportedHostCheck(safeInput, OPENCODE_HOST_ID);
955
+ if (hostFail) return { status: 'unavailable', diagnostics: [hostFail] };
956
+ const qualification = evaluateQualification(safeInput, OPENCODE_HOST_ID);
957
+ if (!qualification.open) return { status: 'unavailable', diagnostics: [qualification.fail] };
958
+ const [captureRef, capability] = String(request.reference).split('#');
959
+ const captures = Array.isArray(safeInput.captures) ? safeInput.captures : [];
960
+ const matches = captures.map((item, index) => ({ item, index })).filter(({ item }) => recordOf(item) && item.call_id === captureRef);
961
+ if (!matches.length) return unavailable('origin-unavailable', `no captured OpenCode event is retained for reference ${request.reference}`);
962
+ if (matches.length > 1) return rejected('replayed-record', 'multiple OpenCode records share this call id');
963
+ const { item: cap, index } = matches[0];
964
+ if (cap.hostId !== OPENCODE_HOST_ID || cap.schema !== OPENCODE_CAPTURE_RECORD_VERSION
965
+ || cap.session_id !== safeInput.sessionId) {
966
+ return rejected('record-origin-mismatch', 'the capture host and session must match this OpenCode provider invocation');
967
+ }
968
+ const observed = cap.observedIdentity;
969
+ const pin = recordOf(safeInput.baseline) ? safeInput.baseline : null;
970
+ if (!recordOf(observed)) return rejected('identity-missing', 'OpenCode capture lacks workflow identity observed at capture time');
971
+ const sameWorkflow = observed.workflowId === safeInput.workflowId && observed.workflowRevision === safeInput.workflowRevision;
972
+ const samePin = !pin || (recordOf(observed.baselinePin) && observed.baselinePin.commit === pin.commit && observed.baselinePin.root === pin.root);
973
+ if (!sameWorkflow || !samePin) return rejected('identity-mismatch', 'the observed workflow revision or baseline pin differs from the current one; stale evidence loses credit');
974
+ const signature = opencodeContentSignature(cap);
975
+ const firstOccurrence = captures.findIndex(item => recordOf(item) && opencodeContentSignature(item) === signature);
976
+ if (firstOccurrence !== index) return rejected('replayed-record', `identical OpenCode tool content already recorded (first occurrence index ${firstOccurrence})`);
977
+ if (cap.error !== undefined && cap.error !== null) return rejected('failure-not-credited', 'the native tool event carries an error');
978
+ if (typeof cap.status !== 'string' || !cap.status) return rejected('outcome-ambiguous', 'the native tool status is missing');
979
+ if (cap.status !== 'completed') return rejected(cap.status === 'error' ? 'failure-not-credited' : 'outcome-ambiguous', `native tool status ${cap.status} does not prove completion`);
980
+ const processTool = ['bash', 'shell', 'powershell', 'cmd'].includes(String(cap.tool).toLowerCase());
981
+ if (processTool && !Number.isInteger(cap.exit)) return rejected('outcome-ambiguous', 'a process result without a numeric native exit is ambiguous');
982
+ if (processTool && cap.exit !== 0) return rejected('failure-not-credited', `the native process exit was ${cap.exit}`);
983
+ if (!/^[0-9a-f]{64}$/.test(String(cap.result_digest || '')) || !Number.isInteger(cap.result_bytes) || cap.result_bytes < 0) {
984
+ return rejected('stdout-withheld', 'the native result digest and byte count are absent');
985
+ }
986
+ const author = recordOf(safeInput.principals) && textOf(safeInput.principals.author) ? safeInput.principals.author : safeInput.sessionId;
987
+ if (capability === 'quality-review') {
988
+ if (!textOf(cap.agent_id)) return rejected('reviewer-principal-missing', 'OpenCode supplied no host-issued reviewer identity');
989
+ if (cap.agent_id === author) return rejected('reviewer-not-independent', 'the reviewer identity equals the author session principal');
990
+ return rejected('reviewer-principal-missing', 'OpenCode supplied no host-issued independent-review record');
991
+ }
992
+ if (capability) {
993
+ if (!['doc-lint', 'link-check'].includes(capability)) return rejected('capability-mismatch', `OpenCode cannot supply capability ${capability}`);
994
+ if (!textOf(cap.command)) return rejected('command-missing', 'the native record has no command for a registered doc-check');
995
+ if (!textOf(cap.cwd)) return rejected('unregistered-command', 'the host omitted per-tool cwd, so the doc-check invocation cannot be validated');
996
+ const registered = resolveDocCheckInvocation(cap.command, cap.cwd);
997
+ if (registered.kind !== 'accepted') return rejected('unregistered-command', `doc-check invocation rejected (${registered.reason}); no credit`);
998
+ return rejected('stdout-withheld', 'the OpenCode result body is retained only as a digest and cannot prove doc-check output');
999
+ }
1000
+ if (!textOf(cap.command)) return rejected('command-missing', 'captured OpenCode record has no retained process command');
1001
+ if (!textOf(cap.cwd)) return rejected('outcome-ambiguous', 'the host omitted per-tool cwd');
1002
+ if (!processTool) return rejected('capture-event-unsupported', `tool ${String(cap.tool)} has no OpenCode process mapping`);
1003
+ if (!recordOf(request.identity)) return rejected('identity-missing', 'execution evidence needs a snapshot identity');
1004
+ const contextResponse = resolveContext(request);
1005
+ if (contextResponse.status !== 'accepted') return unavailable('origin-unavailable', 'origin context did not resolve');
1006
+ const identity = JSON.parse(JSON.stringify(request.identity));
1007
+ const resultRef = `opencode-result/${cap.call_id}/${cap.result_digest}`;
1008
+ const event = {
1009
+ eventId: 'opencode-exec-' + digestText(signature + ':execution'),
1010
+ producerId: 'opencode-origin-provider', originRef: contextResponse.originRef,
1011
+ principalId: cap.session_id, provenance: 'opencode-capture:' + cap.call_id,
1012
+ capability: 'shell-command', kind: 'execution', command: cap.command, argv: [cap.command], cwd: cap.cwd,
1013
+ exitCode: cap.exit, signal: null, toolCorrelationId: cap.call_id, resultRef,
1014
+ identity, startIdentity: identity, endIdentity: identity,
1015
+ paths: [], artifactDigests: {}, captured: { responseRef: resultRef, responseDigest: cap.result_digest },
1016
+ };
1017
+ return { status: 'accepted', originRef: contextResponse.originRef, event,
1018
+ correlation: { toolCorrelationId: cap.call_id, resultRef } };
1019
+ }
1020
+
1021
+ return { resolveContext, resolveEvent };
1022
+ };
1023
+
895
1024
  // ---- Capture record builder (consumed by state-persist.js) ----------------------
896
1025
 
897
1026
  // Builds the retained capture record for a Claude hook payload. The field names
@@ -1028,6 +1157,24 @@ function readCaptureView(sessionDir) {
1028
1157
  return { records: view, tampered: false };
1029
1158
  }
1030
1159
 
1160
+ function readOpencodeCaptureView(sessionDir) {
1161
+ const file = path.join(sessionDir, OPENCODE_CAPTURE_FILE);
1162
+ try {
1163
+ if (fs.statSync(file).size > OPENCODE_CAPTURE_LIMIT_BYTES) return { records: [], tampered: true };
1164
+ const text = fs.readFileSync(file, 'utf8');
1165
+ const records = text.split(/\r?\n/).filter(Boolean).map(line => JSON.parse(line));
1166
+ if (!records.every(item => recordOf(item) && item.schema === OPENCODE_CAPTURE_RECORD_VERSION
1167
+ && item.hostId === OPENCODE_HOST_ID && textOf(item.session_id) && textOf(item.call_id)
1168
+ && textOf(item.tool) && Object.prototype.hasOwnProperty.call(item, 'status'))) {
1169
+ return { records: [], tampered: true };
1170
+ }
1171
+ return { records, tampered: false };
1172
+ } catch (error) {
1173
+ if (error.code === 'ENOENT') return { records: [], tampered: false };
1174
+ return { records: [], tampered: true };
1175
+ }
1176
+ }
1177
+
1031
1178
  // Observation-time identity: the workflow and pin the host event was captured under. Absent before a workflow starts.
1032
1179
  function observedIdentityFor(sessionDir) {
1033
1180
  try {
@@ -1053,7 +1200,8 @@ function reviewRecordIndependent(cap, captures, principals) {
1053
1200
  }
1054
1201
 
1055
1202
  // Keep the old state-persist seam as an alias; it points at the single resolver above.
1056
- module.exports = { createClaudeOriginProvider, buildCaptureRecord, resolveDocCheckInvocation,
1203
+ module.exports = { createClaudeOriginProvider, createOpencodeOriginProvider, buildCaptureRecord, resolveDocCheckInvocation,
1057
1204
  registeredInstalledCheck: resolveDocCheckInvocation, installedDocCheck,
1058
1205
  fileDigest, SHIPPED_DOC_CHECK_DIGEST, trustRisk, journalExecutors, reviewRecordIndependent,
1059
- writeImmutableEvent, rebuildJournalView, readCaptureView, observedIdentityFor, _internals: { captureJson, captureDigest, digestText, canonicalCapture } };
1206
+ writeImmutableEvent, rebuildJournalView, readCaptureView, readOpencodeCaptureView, observedIdentityFor,
1207
+ _internals: { captureJson, captureDigest, digestText, canonicalCapture } };
@@ -14,6 +14,7 @@ function decide(payload) {
14
14
  try {
15
15
  const { planningUnresolved, unresolvedObligations } = require('./lib/workflow-obligations');
16
16
  const context = loadWorkflow(payload);
17
+ context.activeHost = payload && typeof payload.host_id === 'string' ? payload.host_id : undefined;
17
18
  const { workflow, sessionDir } = context;
18
19
  if (!workflow || workflow.state === 'deferred') return;
19
20
  const handoffFile = path.join(sessionDir, 'handoff-state.json');
@@ -93,4 +94,6 @@ function decide(payload) {
93
94
  } catch (error) { warn(error && error.message ? error.message : String(error)); }
94
95
  }
95
96
 
96
- readHookInput(decide);
97
+ if (require.main === module) readHookInput(decide);
98
+
99
+ module.exports = { decide };
@@ -1,9 +1,10 @@
1
1
  /**
2
2
  * Harness Enforcement Plugin for opencode
3
3
  *
4
- * A single self-contained ESM module, because that is what opencode actually
5
- * loads: a JS/TS file dropped in `.opencode/plugins/` (or referenced from
6
- * `opencode.json`) that exports a function returning a hooks object. There is
4
+ * An ESM entry module, because that is what opencode actually loads: a JS/TS
5
+ * file dropped in `.opencode/plugins/` (or referenced from `opencode.json`)
6
+ * that exports a function returning a hooks object. The optional workflow
7
+ * reconciliation path resolves the packaged sibling Stop consumer. There is
7
8
  * no JSON-manifest-to-script mechanism - the old plugin.json + hooks/*.js
8
9
  * split assumed one and was never invoked by opencode. See issue #37.
9
10
  *
@@ -37,11 +38,28 @@
37
38
 
38
39
  import { homedir } from "node:os"
39
40
  import { join, dirname, resolve, basename, relative, isAbsolute } from "node:path"
40
- import { readFileSync, writeFileSync, appendFileSync, existsSync, mkdirSync, rmSync, realpathSync, readdirSync } from "node:fs"
41
+ import { readFileSync, writeFileSync, appendFileSync, existsSync, mkdirSync, rmSync, realpathSync, readdirSync, statSync } from "node:fs"
41
42
  import { execSync } from "node:child_process"
42
43
  import { createHash, randomUUID } from "node:crypto"
44
+ import { createRequire } from "node:module"
45
+ import { fileURLToPath } from "node:url"
43
46
 
44
47
  const EDIT_TOOLS = new Set(["edit", "write", "apply_patch"])
48
+ const OPENCODE_CAPTURE_FILE = "opencode-captures.jsonl"
49
+ const OPENCODE_CAPTURE_LIMIT_BYTES = 4 * 1024 * 1024
50
+ const OPENCODE_CAPTURE_RECORD_LIMIT_BYTES = 16 * 1024
51
+ const requireFromPlugin = createRequire(import.meta.url)
52
+ const STOP_GATE_FILE = join(dirname(fileURLToPath(import.meta.url)), "..", "hooks", "scripts", "workflow-stop-gate.js")
53
+
54
+ function canonicalJson(value) {
55
+ if (Array.isArray(value)) return value.map(canonicalJson)
56
+ if (isRecord(value)) return Object.fromEntries(Object.keys(value).sort().map(key => [key, canonicalJson(value[key])]))
57
+ return value
58
+ }
59
+
60
+ function captureDigest(value) {
61
+ return createHash("sha256").update(JSON.stringify(canonicalJson(value))).digest("hex")
62
+ }
45
63
 
46
64
  // Global state root, matching the Node-side resolver at
47
65
  // scripts/lib/workspace.js#getStateHome (same env var precedence) - this
@@ -744,6 +762,50 @@ export const HarnessEnforcement = async ({ client, directory }) => {
744
762
  }
745
763
  }
746
764
 
765
+ function recordHostCapture(input, output) {
766
+ try {
767
+ if (!isRecord(output) || output.status === undefined || typeof input?.sessionID !== "string" || !input.sessionID
768
+ || typeof input.callID !== "string" || !input.callID || typeof input.tool !== "string" || !input.tool) return
769
+ const stateDir = pathsFor(input.sessionID).stateDir
770
+ const file = join(stateDir, OPENCODE_CAPTURE_FILE)
771
+ const args = isRecord(input.args) ? input.args : {}
772
+ const record = {
773
+ schema: "opencode-capture-record/1",
774
+ hostId: "opencode",
775
+ session_id: input.sessionID,
776
+ call_id: input.callID,
777
+ ...(typeof output.messageID === "string" ? { message_id: output.messageID } : {}),
778
+ tool: input.tool,
779
+ status: output.status,
780
+ ...(typeof args.command === "string" ? { command: args.command } : {}),
781
+ ...(typeof args.cwd === "string" ? { cwd: args.cwd } : {}),
782
+ ...(isRecord(output.result?.metadata) && typeof output.result.metadata.exit === "number" && Number.isFinite(output.result.metadata.exit)
783
+ ? { exit: output.result.metadata.exit } : {}),
784
+ ...(output.result !== undefined
785
+ ? { result_digest: captureDigest(output.result), result_bytes: Buffer.byteLength(JSON.stringify(canonicalJson(output.result))) } : {}),
786
+ ...(output.error !== undefined && output.error !== null
787
+ ? { error: isRecord(output.error)
788
+ ? Object.fromEntries(["name", "message", "code"].filter(key => typeof output.error[key] === "string").map(key => [key, output.error[key].slice(0, 300)]))
789
+ : typeof output.error === "string" ? { message: output.error.slice(0, 300) } : {} } : {}),
790
+ ts: new Date().toISOString(),
791
+ }
792
+ try {
793
+ const workflow = JSON.parse(readFileSync(join(stateDir, "workflow-run.json"), "utf8"))
794
+ if (typeof workflow.workflowId === "string" && Number.isInteger(workflow.revision)) {
795
+ const pin = isRecord(workflow.docReconciliationBaseline) ? workflow.docReconciliationBaseline : {}
796
+ record.observedIdentity = { workflowId: workflow.workflowId, workflowRevision: workflow.revision,
797
+ baselinePin: typeof pin.commit === "string" && typeof pin.root === "string" ? { commit: pin.commit, root: pin.root } : null }
798
+ }
799
+ } catch { /* pre-workflow events cannot be bound to a later workflow */ }
800
+ const line = `${JSON.stringify(record)}\n`
801
+ if (Buffer.byteLength(line) > OPENCODE_CAPTURE_RECORD_LIMIT_BYTES) return
802
+ mkdirSync(stateDir, { recursive: true })
803
+ const size = existsSync(file) ? statSync(file).size : 0
804
+ if (size + Buffer.byteLength(line) > OPENCODE_CAPTURE_LIMIT_BYTES) return
805
+ appendFileSync(file, line, "utf8")
806
+ } catch { /* capture is diagnostic only; an unavailable journal never grants credit */ }
807
+ }
808
+
747
809
  function resetSession(sessionID) {
748
810
  const { stateDir } = pathsFor(sessionID)
749
811
  const sessionsRoot = resolve(join(getStateRoot(workspace), "sessions"))
@@ -789,6 +851,7 @@ export const HarnessEnforcement = async ({ client, directory }) => {
789
851
 
790
852
  "tool.execute.after": async (input, output) => {
791
853
  telemetryAfter(input)
854
+ recordHostCapture(input, output)
792
855
  if (!EDIT_TOOLS.has(input.tool)) return
793
856
  const { editStateFile, breakerFile, complianceFile, reflectionFile } = pathsFor(input.sessionID)
794
857
 
@@ -927,6 +990,9 @@ export const HarnessEnforcement = async ({ client, directory }) => {
927
990
  saveJSON(editStateFile, failedState)
928
991
  }
929
992
  } finally {
993
+ try {
994
+ requireFromPlugin(STOP_GATE_FILE).decide({ session_id: activeSession, cwd: workspace, host_id: "opencode" })
995
+ } catch { /* the optional reconciliation consumer must never block OpenCode idle */ }
930
996
  activeVerifications.delete(activeSession)
931
997
  }
932
998
  },
@@ -971,7 +1037,13 @@ export default {
971
1037
  await ctx.tool.hook("execute.before", (event) =>
972
1038
  hooks["tool.execute.before"](toolEventInput(event), { args: event.input }))
973
1039
  await ctx.tool.hook("execute.after", (event) =>
974
- hooks["tool.execute.after"](toolEventInput(event), { args: event.input }))
1040
+ hooks["tool.execute.after"](toolEventInput(event), {
1041
+ args: event.input,
1042
+ status: event.status,
1043
+ result: event.result,
1044
+ error: event.error,
1045
+ messageID: event.messageID,
1046
+ }))
975
1047
 
976
1048
  const events = new AbortController()
977
1049
  void (async () => {
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "harness-enforcement",
3
3
  "description": "Repository inventory for the opencode enforcement plugin and its standalone helper",
4
- "version": "0.41.3",
4
+ "version": "0.42.0",
5
5
  "runtime": "index.mjs",
6
6
  "hooks": {
7
7
  "verify": "hooks/verify.js"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "harness-everything",
3
- "version": "0.41.3",
3
+ "version": "0.42.0",
4
4
  "description": "Harness OS: An Orchestrated Agent Operating System for AI-driven Development",
5
5
  "main": "harness-everything/scripts/bootstrap.js",
6
6
  "bin": {
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "harness-everything",
3
- "version": "0.41.3",
3
+ "version": "0.42.0",
4
4
  "description": "Invariant-first engineering harness for routing, verification, and failure recovery while preserving agent autonomy.",
5
5
  "author": {
6
6
  "name": "Miya Daniel",
@@ -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');