@markstanden/opencode-test-container 0.0.0-stage → 0.1.1

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.
Files changed (123) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +263 -2
  3. package/container/Containerfile +24 -0
  4. package/container/README.md +29 -0
  5. package/dist/adapters/opencode/events.d.ts +90 -0
  6. package/dist/adapters/opencode/events.js +213 -0
  7. package/dist/adapters/opencode/runtime.d.ts +87 -0
  8. package/dist/adapters/opencode/runtime.js +136 -0
  9. package/dist/adapters/podman/container.d.ts +62 -0
  10. package/dist/adapters/podman/container.js +119 -0
  11. package/dist/adapters/podman/image.d.ts +50 -0
  12. package/dist/adapters/podman/image.js +124 -0
  13. package/dist/adapters/process.d.ts +38 -0
  14. package/dist/adapters/process.js +71 -0
  15. package/dist/campaign/assess.d.ts +45 -0
  16. package/dist/campaign/assess.js +208 -0
  17. package/dist/campaign/close.d.ts +17 -0
  18. package/dist/campaign/close.js +112 -0
  19. package/dist/campaign/evidence-guard.d.ts +24 -0
  20. package/dist/campaign/evidence-guard.js +49 -0
  21. package/dist/campaign/freeze.d.ts +94 -0
  22. package/dist/campaign/freeze.js +297 -0
  23. package/dist/campaign/inventory.d.ts +62 -0
  24. package/dist/campaign/inventory.js +192 -0
  25. package/dist/campaign/judge.d.ts +209 -0
  26. package/dist/campaign/judge.js +1285 -0
  27. package/dist/campaign/ledger.d.ts +64 -0
  28. package/dist/campaign/ledger.js +149 -0
  29. package/dist/campaign/prepare.d.ts +129 -0
  30. package/dist/campaign/prepare.js +483 -0
  31. package/dist/campaign/primary.d.ts +96 -0
  32. package/dist/campaign/primary.js +189 -0
  33. package/dist/campaign/run.d.ts +151 -0
  34. package/dist/campaign/run.js +562 -0
  35. package/dist/campaign/status.d.ts +76 -0
  36. package/dist/campaign/status.js +237 -0
  37. package/dist/cli/otc.d.ts +51 -0
  38. package/dist/cli/otc.js +435 -0
  39. package/dist/contracts/canonical.d.ts +21 -0
  40. package/dist/contracts/canonical.js +44 -0
  41. package/dist/contracts/types.d.ts +261 -0
  42. package/dist/contracts/types.js +9 -0
  43. package/dist/contracts/validate.d.ts +72 -0
  44. package/dist/contracts/validate.js +1021 -0
  45. package/dist/contracts/version.d.ts +44 -0
  46. package/dist/contracts/version.js +147 -0
  47. package/dist/evaluators/docs/analysis/accounting.d.ts +140 -0
  48. package/dist/evaluators/docs/analysis/accounting.js +237 -0
  49. package/dist/evaluators/docs/analysis/blinding.d.ts +40 -0
  50. package/dist/evaluators/docs/analysis/blinding.js +80 -0
  51. package/dist/evaluators/docs/analysis/bounds.d.ts +40 -0
  52. package/dist/evaluators/docs/analysis/bounds.js +31 -0
  53. package/dist/evaluators/docs/analysis/budget.d.ts +137 -0
  54. package/dist/evaluators/docs/analysis/budget.js +305 -0
  55. package/dist/evaluators/docs/analysis/closure.d.ts +175 -0
  56. package/dist/evaluators/docs/analysis/closure.js +251 -0
  57. package/dist/evaluators/docs/analysis/decision.d.ts +59 -0
  58. package/dist/evaluators/docs/analysis/decision.js +161 -0
  59. package/dist/evaluators/docs/analysis/freeze.d.ts +201 -0
  60. package/dist/evaluators/docs/analysis/freeze.js +395 -0
  61. package/dist/evaluators/docs/analysis/guardrails.d.ts +51 -0
  62. package/dist/evaluators/docs/analysis/guardrails.js +194 -0
  63. package/dist/evaluators/docs/analysis/journal.d.ts +150 -0
  64. package/dist/evaluators/docs/analysis/journal.js +475 -0
  65. package/dist/evaluators/docs/analysis/ledger.d.ts +78 -0
  66. package/dist/evaluators/docs/analysis/ledger.js +145 -0
  67. package/dist/evaluators/docs/analysis/measures.d.ts +302 -0
  68. package/dist/evaluators/docs/analysis/measures.js +1069 -0
  69. package/dist/evaluators/docs/analysis/model.d.ts +236 -0
  70. package/dist/evaluators/docs/analysis/model.js +740 -0
  71. package/dist/evaluators/docs/analysis/pairing.d.ts +68 -0
  72. package/dist/evaluators/docs/analysis/pairing.js +95 -0
  73. package/dist/evaluators/docs/analysis/records.d.ts +229 -0
  74. package/dist/evaluators/docs/analysis/records.js +935 -0
  75. package/dist/evaluators/docs/analysis/recovery.d.ts +225 -0
  76. package/dist/evaluators/docs/analysis/recovery.js +507 -0
  77. package/dist/evaluators/docs/analysis/render.d.ts +235 -0
  78. package/dist/evaluators/docs/analysis/render.js +566 -0
  79. package/dist/evaluators/docs/analysis/replay.d.ts +15 -0
  80. package/dist/evaluators/docs/analysis/replay.js +1272 -0
  81. package/dist/evaluators/docs/analysis/screening/inventory.d.ts +136 -0
  82. package/dist/evaluators/docs/analysis/screening/inventory.js +307 -0
  83. package/dist/evaluators/docs/analysis/screening/noteVersion.d.ts +15 -0
  84. package/dist/evaluators/docs/analysis/screening/noteVersion.js +46 -0
  85. package/dist/evaluators/docs/analysis/screening/reconcile.d.ts +38 -0
  86. package/dist/evaluators/docs/analysis/screening/reconcile.js +40 -0
  87. package/dist/evaluators/docs/analysis/screening/rules.d.ts +163 -0
  88. package/dist/evaluators/docs/analysis/screening/rules.js +330 -0
  89. package/dist/evaluators/docs/analysis/screening/scan.d.ts +72 -0
  90. package/dist/evaluators/docs/analysis/screening/scan.js +362 -0
  91. package/dist/evaluators/docs/analysis/selector.d.ts +68 -0
  92. package/dist/evaluators/docs/analysis/selector.js +123 -0
  93. package/dist/evaluators/docs/assess.d.ts +129 -0
  94. package/dist/evaluators/docs/assess.js +433 -0
  95. package/dist/evaluators/docs/campaign.d.ts +133 -0
  96. package/dist/evaluators/docs/campaign.js +222 -0
  97. package/dist/evaluators/docs/index.d.ts +25 -0
  98. package/dist/evaluators/docs/index.js +25 -0
  99. package/dist/evaluators/docs/journal.d.ts +150 -0
  100. package/dist/evaluators/docs/journal.js +475 -0
  101. package/dist/evaluators/docs/scoring.d.ts +367 -0
  102. package/dist/evaluators/docs/scoring.js +662 -0
  103. package/dist/evaluators/skills/assess.d.ts +94 -0
  104. package/dist/evaluators/skills/assess.js +207 -0
  105. package/dist/index.d.ts +46 -0
  106. package/dist/index.js +29 -0
  107. package/dist/legacy/prod014.d.ts +86 -0
  108. package/dist/legacy/prod014.js +209 -0
  109. package/dist/reporting/report.d.ts +79 -0
  110. package/dist/reporting/report.js +242 -0
  111. package/docs/architecture.md +106 -0
  112. package/docs/capability-matrix.md +84 -0
  113. package/docs/offline-rehearsal.md +75 -0
  114. package/docs/parity-record.md +81 -0
  115. package/docs/pr-description.md +103 -0
  116. package/docs/release-0.1.0.md +52 -0
  117. package/docs/releasing.md +82 -0
  118. package/docs/schema.md +97 -0
  119. package/examples/skills/README.md +13 -0
  120. package/examples/skills/campaign.json +73 -0
  121. package/examples/skills/skills/otel-helper/SKILL.md +10 -0
  122. package/examples/skills/suite.json +29 -0
  123. package/package.json +45 -5
@@ -0,0 +1,150 @@
1
+ /**
2
+ * The one checked evidence boundary for the plugin's operation journal.
3
+ *
4
+ * The plugin can write an opt-in, HMAC-signed journal of served docs
5
+ * operations. That journal is the only record of a nested Code Mode call, so
6
+ * it is also the easiest evidence to forge. This module decides what the
7
+ * journal proves, and nothing else in the harness or analyser may credit a
8
+ * journal line it did not verify.
9
+ *
10
+ * Trust boundary: the signing key and the journal are both harness output,
11
+ * copied after the trial process exits. A trial tool cannot acquire the key
12
+ * during the run, so a correctly signed line with this run's session, message
13
+ * and execute identities, matching a host-recorded dispatch one-to-one, is
14
+ * evidence the plugin served that call. The key is held locally beside the
15
+ * evidence; possession of that key is possession of the ability to forge, so
16
+ * the verification is tamper-evident, not tamper-proof.
17
+ */
18
+ /** The session and message identities observed in one run's CLI trail. */
19
+ export interface JournalIdentity {
20
+ /** Session ids the run emitted. */
21
+ sessions: ReadonlySet<string>;
22
+ /** Message ids the run emitted. */
23
+ messages: ReadonlySet<string>;
24
+ }
25
+ /** One docs call the host recorded, native or nested. */
26
+ export interface JournalDispatch {
27
+ /** The journal's registered tool name (`docs` or `docs_search`). */
28
+ tool: string;
29
+ /** The dispatch status the host recorded. */
30
+ status: string;
31
+ /** The dispatch input the host recorded. */
32
+ input: unknown;
33
+ /** The host call id (native) or parent execute id (nested). */
34
+ callId: string | null;
35
+ }
36
+ /** One credited journal line. */
37
+ export interface CreditedJournalRecord {
38
+ /** The zero-based line index. */
39
+ index: number;
40
+ /** The verified record, including its MAC. */
41
+ record: Record<string, unknown>;
42
+ }
43
+ /** One rejected journal line and why. */
44
+ export interface RejectedJournalRecord {
45
+ /** The zero-based line index. */
46
+ index: number;
47
+ /** A stable reason slug, suitable for diagnostics. */
48
+ reason: "not-json" | "not-served-twin" | "bad-mac" | "unattributed" | "no-host-dispatch";
49
+ }
50
+ /** The explicit result of verifying a journal. */
51
+ export type JournalVerification = {
52
+ /** No journal was captured: the pre-instrumentation status quo. */
53
+ state: "absent";
54
+ credited: [];
55
+ rejected: [];
56
+ problems: string[];
57
+ } | {
58
+ /** A journal exists but its signing key does not. Nothing is credited. */
59
+ state: "unverifiable-key";
60
+ credited: [];
61
+ rejected: RejectedJournalRecord[];
62
+ problems: string[];
63
+ } | {
64
+ /** The journal was verified; `credited` is the only usable evidence. */
65
+ state: "verified";
66
+ credited: CreditedJournalRecord[];
67
+ rejected: RejectedJournalRecord[];
68
+ problems: string[];
69
+ };
70
+ /** Whether a record names a served docs call on either registration surface. */
71
+ export declare function isServedRecord(record: Record<string, unknown>): boolean;
72
+ /**
73
+ * Whether a record's MAC matches the given key. The MAC covers every field
74
+ * except `mac`, in the record's own member order, exactly as the plugin writes
75
+ * it.
76
+ * @param record The journal record.
77
+ * @param key The run's signing key.
78
+ * @returns True when the MAC is well formed and matches.
79
+ */
80
+ export declare function journalMacValid(record: Record<string, unknown>, key: string): boolean;
81
+ /**
82
+ * Whether a record carries this run's observed identity and names an execute
83
+ * call present in its trail.
84
+ * @param record The journal record.
85
+ * @param identity The run's observed session/message ids.
86
+ * @param callIds The execute call ids in the run's trail.
87
+ * @returns True when every identity matches exactly.
88
+ */
89
+ export declare function journalRecordAttributed(record: Record<string, unknown>, identity: JournalIdentity, callIds: ReadonlySet<string>): boolean;
90
+ /**
91
+ * Verify one captured journal against this run's identity and host dispatches.
92
+ *
93
+ * Order matters: shape, then MAC, then identity, then one-to-one dispatch
94
+ * matching. A line failing any step is rejected with its reason and is never
95
+ * credited. If a journal exists but no usable key does, nothing is credited
96
+ * and the state says why.
97
+ * @param input The captured lines, key and run observations.
98
+ * @param input.lines The captured journal lines, or undefined when absent.
99
+ * @param input.key The run's signing key, or null when unavailable.
100
+ * @param input.identity The run's observed session and message ids.
101
+ * @param input.callIds The execute call ids in the run's trail.
102
+ * @param input.nativeCalls The docs calls the host recorded directly.
103
+ * @param input.dispatches The nested docs dispatches the host recorded.
104
+ * @returns The explicit verification result.
105
+ */
106
+ export declare function verifyOperationsJournal(input: {
107
+ lines: readonly string[] | undefined;
108
+ key: string | null | undefined;
109
+ identity: JournalIdentity;
110
+ callIds: ReadonlySet<string>;
111
+ nativeCalls?: readonly JournalDispatch[];
112
+ dispatches: readonly JournalDispatch[];
113
+ }): JournalVerification;
114
+ /**
115
+ * Collect the session and message identities a saved CLI trail observed.
116
+ * @param stdoutText The saved `stdout.jsonl` bytes.
117
+ * @returns The identity sets; empty when the trail is unreadable.
118
+ */
119
+ export declare function journalIdentityFromTrail(stdoutText: string): JournalIdentity;
120
+ /** The host half of journal attribution, read from a saved tools trail. */
121
+ export interface HostDispatches {
122
+ /** Host call ids present in the trail, native and execute. */
123
+ callIds: Set<string>;
124
+ /** Native docs calls the host recorded directly. */
125
+ nativeCalls: JournalDispatch[];
126
+ /** Nested docs dispatches the host recorded inside execute results. */
127
+ dispatches: JournalDispatch[];
128
+ }
129
+ /**
130
+ * Extract the host dispatches and execute call ids from a saved `tools.jsonl`.
131
+ * @param toolsText The saved tools trail bytes.
132
+ * @returns The host half of attribution; empty when the trail is unreadable.
133
+ */
134
+ export declare function hostDispatchesFromTrail(toolsText: string): HostDispatches;
135
+ /**
136
+ * Check a captured answer against the producer's `meta.answerHash`.
137
+ *
138
+ * The harness hashes the exact final-answer text as bytes (`sha256(text)`),
139
+ * so the analyser must hash the recorded `final.md` bytes, never a normalised
140
+ * rendering. A missing hash leaves the answer unbound rather than trusted.
141
+ * @param answer The recorded answer bytes.
142
+ * @param answerHash The producer's recorded hash, when present.
143
+ * @param settings The optional binding settings.
144
+ * @param settings.allowUnbound Explicit read-only coverage for legacy evidence
145
+ * written before the producer recorded a hash.
146
+ * @returns A problem, or null when the answer is bound.
147
+ */
148
+ export declare function answerIdentityProblem(answer: string, answerHash: unknown, settings?: {
149
+ allowUnbound?: boolean;
150
+ }): string | null;
@@ -0,0 +1,475 @@
1
+ /**
2
+ * The one checked evidence boundary for the plugin's operation journal.
3
+ *
4
+ * The plugin can write an opt-in, HMAC-signed journal of served docs
5
+ * operations. That journal is the only record of a nested Code Mode call, so
6
+ * it is also the easiest evidence to forge. This module decides what the
7
+ * journal proves, and nothing else in the harness or analyser may credit a
8
+ * journal line it did not verify.
9
+ *
10
+ * Trust boundary: the signing key and the journal are both harness output,
11
+ * copied after the trial process exits. A trial tool cannot acquire the key
12
+ * during the run, so a correctly signed line with this run's session, message
13
+ * and execute identities, matching a host-recorded dispatch one-to-one, is
14
+ * evidence the plugin served that call. The key is held locally beside the
15
+ * evidence; possession of that key is possession of the ability to forge, so
16
+ * the verification is tamper-evident, not tamper-proof.
17
+ */
18
+ import { createHash, createHmac, timingSafeEqual } from "node:crypto";
19
+ import { isDeepStrictEqual } from "node:util";
20
+ /** Whether a value is a plain JSON object. */
21
+ function isRecord(value) {
22
+ return typeof value === "object" && value !== null && !Array.isArray(value);
23
+ }
24
+ /** Parse one journal line, or undefined when it is not a JSON object. */
25
+ function parseLine(line) {
26
+ try {
27
+ const value = JSON.parse(line);
28
+ return isRecord(value) ? value : undefined;
29
+ }
30
+ catch {
31
+ return undefined;
32
+ }
33
+ }
34
+ /** Whether a record names a served docs call on either registration surface. */
35
+ export function isServedRecord(record) {
36
+ if (record.schemaVersion !== 1)
37
+ return false;
38
+ if (record.tool !== "docs" && record.tool !== "docs_search")
39
+ return false;
40
+ if (record.surface !== "native" && record.surface !== "codemode-twin") {
41
+ return false;
42
+ }
43
+ return isRecord(record.input);
44
+ }
45
+ /**
46
+ * Whether a record's MAC matches the given key. The MAC covers every field
47
+ * except `mac`, in the record's own member order, exactly as the plugin writes
48
+ * it.
49
+ * @param record The journal record.
50
+ * @param key The run's signing key.
51
+ * @returns True when the MAC is well formed and matches.
52
+ */
53
+ export function journalMacValid(record, key) {
54
+ const mac = record.mac;
55
+ if (typeof mac !== "string")
56
+ return false;
57
+ const body = Object.fromEntries(Object.entries(record).filter(([field]) => field !== "mac"));
58
+ const expected = createHmac("sha256", key)
59
+ .update(JSON.stringify(body), "utf8")
60
+ .digest("hex");
61
+ const left = Buffer.from(expected, "hex");
62
+ const right = Buffer.from(mac, "hex");
63
+ return left.length === right.length && timingSafeEqual(left, right);
64
+ }
65
+ /**
66
+ * Whether a record carries this run's observed identity and names an execute
67
+ * call present in its trail.
68
+ * @param record The journal record.
69
+ * @param identity The run's observed session/message ids.
70
+ * @param callIds The execute call ids in the run's trail.
71
+ * @returns True when every identity matches exactly.
72
+ */
73
+ export function journalRecordAttributed(record, identity, callIds) {
74
+ if (typeof record.sessionID !== "string" ||
75
+ !identity.sessions.has(record.sessionID)) {
76
+ return false;
77
+ }
78
+ if (typeof record.messageID !== "string" ||
79
+ !identity.messages.has(record.messageID)) {
80
+ return false;
81
+ }
82
+ return typeof record.callID === "string" && callIds.has(record.callID);
83
+ }
84
+ /** The signed result's metadata, when it is present as an object. */
85
+ function resultMetadataOf(record) {
86
+ const result = record.result;
87
+ if (!isRecord(result))
88
+ return undefined;
89
+ return isRecord(result.metadata) ? result.metadata : undefined;
90
+ }
91
+ /**
92
+ * The signed add result's write fact, or null when it does not establish one.
93
+ * @param record The signed journal record.
94
+ * @returns Whether a write happened and the lake it names, or null.
95
+ */
96
+ function addWriteOf(record) {
97
+ const metadata = resultMetadataOf(record);
98
+ if (metadata === undefined)
99
+ return null;
100
+ if (metadata.written !== true && metadata.written !== false)
101
+ return null;
102
+ return {
103
+ written: metadata.written,
104
+ lake: typeof metadata.lake === "string" ? metadata.lake : null,
105
+ };
106
+ }
107
+ /**
108
+ * Whether the signed result permits removing the nested lake. A refused add
109
+ * wrote nothing; a successful write must name the host's top-level lake.
110
+ * @param record The signed journal record.
111
+ * @param hostInput The host input, with a string-form add already decoded.
112
+ * @returns True when the write facts make the removal safe.
113
+ */
114
+ function writeAllowsUndeliveredAddLake(record, hostInput) {
115
+ const write = addWriteOf(record);
116
+ if (write === null)
117
+ return false;
118
+ if (!write.written)
119
+ return true;
120
+ return (typeof hostInput.lake === "string" &&
121
+ write.lake !== null &&
122
+ hostInput.lake === write.lake);
123
+ }
124
+ /**
125
+ * Remove only the nested `add.lake` the runtime discards before execute.
126
+ *
127
+ * The tool schema declares `lake` at the top level; Effect Schema's default
128
+ * excess-property handling drops the undeclared nested field. A successful
129
+ * write therefore binds only when the host's top-level selector is present and
130
+ * equals the lake the signed result says was written. A refused add wrote
131
+ * nothing, so it binds without a destination. Every other shape refuses: the
132
+ * comparator never drops another field, and the nested value never becomes the
133
+ * credited destination.
134
+ * @param record The signed journal record.
135
+ * @param candidate The decoded host input and its two add objects.
136
+ * @returns The comparable host input, or null when this shape is not safe.
137
+ */
138
+ function withoutUndeliveredAddLake(record, candidate) {
139
+ if (!Object.hasOwn(candidate.hostAdd, "lake"))
140
+ return null;
141
+ if (Object.hasOwn(candidate.recordAdd, "lake"))
142
+ return null;
143
+ if (!writeAllowsUndeliveredAddLake(record, candidate.hostInput)) {
144
+ return null;
145
+ }
146
+ const add = { ...candidate.hostAdd };
147
+ delete add.lake;
148
+ return { ...candidate.hostInput, add };
149
+ }
150
+ /**
151
+ * The host input with a string-form add decoded once, when this is a native
152
+ * add shape. A non-add call, a malformed string, a double-encoded string or a
153
+ * non-object add returns null.
154
+ */
155
+ function decodedHostInput(record, hostInput) {
156
+ if (!isRecord(record.input) || !isRecord(record.input.add))
157
+ return null;
158
+ if (typeof hostInput.add === "string") {
159
+ let decoded;
160
+ try {
161
+ decoded = JSON.parse(hostInput.add);
162
+ }
163
+ catch {
164
+ return null;
165
+ }
166
+ if (!isRecord(decoded))
167
+ return null;
168
+ return {
169
+ hostInput: { ...hostInput, add: decoded },
170
+ recordAdd: record.input.add,
171
+ hostAdd: decoded,
172
+ };
173
+ }
174
+ if (!isRecord(hostInput.add))
175
+ return null;
176
+ return {
177
+ hostInput,
178
+ recordAdd: record.input.add,
179
+ hostAdd: hostInput.add,
180
+ };
181
+ }
182
+ /**
183
+ * Whether one host dispatch carries the signed input.
184
+ *
185
+ * Exact equality is the primary match. A native docs call can also carry
186
+ * `add` as a JSON string: the runtime decodes it into the signed struct before
187
+ * the plugin executes it, so only the raw string reaches the host trail.
188
+ * Decode that one field once and require the whole resulting input to be
189
+ * deeply equal to the signed input. The runtime also discards an undeclared
190
+ * `add.lake` before execute; that one field is removed under the signed
191
+ * result's write facts, never generally. A malformed, double-encoded, one-way
192
+ * or otherwise differing value still refuses. The signed record is never
193
+ * rewritten: its bytes are the MAC-covered evidence.
194
+ * @param record The signed journal record.
195
+ * @param dispatch The host-recorded dispatch.
196
+ * @returns True when the host input matches the signed input, exactly or
197
+ * through the one decoded-string/undelivered-nested-field exception.
198
+ */
199
+ function hostInputMatches(record, dispatch) {
200
+ if (isDeepStrictEqual(dispatch.input, record.input))
201
+ return true;
202
+ if (record.surface !== "native" || record.tool !== "docs")
203
+ return false;
204
+ if (!isRecord(dispatch.input) || !isRecord(record.input))
205
+ return false;
206
+ const candidate = decodedHostInput(record, dispatch.input);
207
+ if (candidate === null)
208
+ return false;
209
+ if (isDeepStrictEqual(candidate.hostInput, record.input))
210
+ return true;
211
+ const normalized = withoutUndeliveredAddLake(record, candidate);
212
+ return normalized !== null && isDeepStrictEqual(normalized, record.input);
213
+ }
214
+ /**
215
+ * Consume one matching host dispatch, so two journal lines can never both
216
+ * claim one dispatch.
217
+ * @param host The remaining host calls and dispatches.
218
+ * @param record The journal record.
219
+ * @returns True when one dispatch was consumed.
220
+ */
221
+ function consumeDispatch(host, record) {
222
+ const native = record.surface === "native";
223
+ const candidates = native ? host.nativeCalls : host.dispatches;
224
+ const match = candidates.findIndex((dispatch) => dispatch.callId === record.callID &&
225
+ dispatch.tool === record.tool &&
226
+ dispatch.status === record.status &&
227
+ hostInputMatches(record, dispatch));
228
+ if (match < 0)
229
+ return false;
230
+ candidates.splice(match, 1);
231
+ return true;
232
+ }
233
+ /**
234
+ * Verify one captured journal against this run's identity and host dispatches.
235
+ *
236
+ * Order matters: shape, then MAC, then identity, then one-to-one dispatch
237
+ * matching. A line failing any step is rejected with its reason and is never
238
+ * credited. If a journal exists but no usable key does, nothing is credited
239
+ * and the state says why.
240
+ * @param input The captured lines, key and run observations.
241
+ * @param input.lines The captured journal lines, or undefined when absent.
242
+ * @param input.key The run's signing key, or null when unavailable.
243
+ * @param input.identity The run's observed session and message ids.
244
+ * @param input.callIds The execute call ids in the run's trail.
245
+ * @param input.nativeCalls The docs calls the host recorded directly.
246
+ * @param input.dispatches The nested docs dispatches the host recorded.
247
+ * @returns The explicit verification result.
248
+ */
249
+ export function verifyOperationsJournal(input) {
250
+ if (input.lines === undefined) {
251
+ return {
252
+ state: "absent",
253
+ credited: [],
254
+ rejected: [],
255
+ problems: [],
256
+ };
257
+ }
258
+ const key = typeof input.key === "string" && input.key.trim() !== ""
259
+ ? input.key.trim()
260
+ : null;
261
+ if (key === null) {
262
+ return {
263
+ state: "unverifiable-key",
264
+ credited: [],
265
+ rejected: input.lines.flatMap((line, index) => line.trim() === ""
266
+ ? []
267
+ : [{ index, reason: "not-json" }]),
268
+ problems: [
269
+ "a docs-operation journal exists without a usable signing key; no journal line is credited",
270
+ ],
271
+ };
272
+ }
273
+ const host = {
274
+ callIds: new Set(input.callIds),
275
+ nativeCalls: input.nativeCalls ? [...input.nativeCalls] : [],
276
+ dispatches: [...input.dispatches],
277
+ };
278
+ const credited = [];
279
+ const rejected = [];
280
+ for (const [index, line] of input.lines.entries()) {
281
+ const outcome = verifyLine(line, index, key, input, host);
282
+ if (outcome.rejected !== undefined)
283
+ rejected.push(outcome.rejected);
284
+ if (outcome.credited !== undefined)
285
+ credited.push(outcome.credited);
286
+ }
287
+ return { state: "verified", credited, rejected, problems: [] };
288
+ }
289
+ /**
290
+ * Verify one journal line and consume its host dispatch when it is credited.
291
+ * @param line The raw line.
292
+ * @param index The zero-based line index.
293
+ * @param key The run's signing key.
294
+ * @param input The run's observed identity and call ids.
295
+ * @param input.identity The run's observed session/message ids.
296
+ * @param input.callIds The execute call ids in the trail.
297
+ * @param host The mutable host calls and dispatches.
298
+ * @returns The credited record or the rejection.
299
+ */
300
+ function verifyLine(line, index, key, input, host) {
301
+ if (line.trim() === "")
302
+ return {};
303
+ const record = parseLine(line);
304
+ if (record === undefined)
305
+ return { rejected: { index, reason: "not-json" } };
306
+ if (!isServedRecord(record)) {
307
+ return { rejected: { index, reason: "not-served-twin" } };
308
+ }
309
+ if (!journalMacValid(record, key)) {
310
+ return { rejected: { index, reason: "bad-mac" } };
311
+ }
312
+ if (!journalRecordAttributed(record, input.identity, input.callIds)) {
313
+ return { rejected: { index, reason: "unattributed" } };
314
+ }
315
+ if (!consumeDispatch(host, record)) {
316
+ return { rejected: { index, reason: "no-host-dispatch" } };
317
+ }
318
+ return { credited: { index, record } };
319
+ }
320
+ /**
321
+ * Collect the session and message identities a saved CLI trail observed.
322
+ * @param stdoutText The saved `stdout.jsonl` bytes.
323
+ * @returns The identity sets; empty when the trail is unreadable.
324
+ */
325
+ export function journalIdentityFromTrail(stdoutText) {
326
+ const identity = {
327
+ sessions: new Set(),
328
+ messages: new Set(),
329
+ };
330
+ for (const line of stdoutText.split("\n")) {
331
+ if (line.trim() === "")
332
+ continue;
333
+ let event;
334
+ try {
335
+ event = JSON.parse(line);
336
+ }
337
+ catch {
338
+ continue;
339
+ }
340
+ if (isRecord(event))
341
+ addEventIdentity(event, identity);
342
+ }
343
+ return identity;
344
+ }
345
+ /**
346
+ * Add one event's session and message ids, including a nested part's.
347
+ * @param event The parsed event.
348
+ * @param identity The accumulating identity sets.
349
+ * @param identity.sessions The observed session ids.
350
+ * @param identity.messages The observed message ids.
351
+ */
352
+ function addEventIdentity(event, identity) {
353
+ if (typeof event.sessionID === "string") {
354
+ identity.sessions.add(event.sessionID);
355
+ }
356
+ if (typeof event.messageID === "string") {
357
+ identity.messages.add(event.messageID);
358
+ }
359
+ const part = isRecord(event.part) ? event.part : undefined;
360
+ if (part === undefined)
361
+ return;
362
+ if (typeof part.sessionID === "string") {
363
+ identity.sessions.add(part.sessionID);
364
+ }
365
+ if (typeof part.messageID === "string") {
366
+ identity.messages.add(part.messageID);
367
+ }
368
+ }
369
+ /** The journal tool name for one host dispatch name. */
370
+ function dispatchTool(name) {
371
+ if (name === "docs.docs_search")
372
+ return "docs_search";
373
+ if (name === "docs")
374
+ return "docs";
375
+ return null;
376
+ }
377
+ /**
378
+ * Extract the host dispatches and execute call ids from a saved `tools.jsonl`.
379
+ * @param toolsText The saved tools trail bytes.
380
+ * @returns The host half of attribution; empty when the trail is unreadable.
381
+ */
382
+ export function hostDispatchesFromTrail(toolsText) {
383
+ const result = {
384
+ callIds: new Set(),
385
+ nativeCalls: [],
386
+ dispatches: [],
387
+ };
388
+ for (const line of toolsText.split("\n")) {
389
+ if (line.trim() === "")
390
+ continue;
391
+ let entry;
392
+ try {
393
+ entry = JSON.parse(line);
394
+ }
395
+ catch {
396
+ continue;
397
+ }
398
+ if (isRecord(entry))
399
+ addHostDispatches(entry, result);
400
+ }
401
+ return result;
402
+ }
403
+ /**
404
+ * Add one tool-trail entry's execute id and docs dispatches.
405
+ * @param entry The parsed trail entry.
406
+ * @param result The accumulating host result.
407
+ */
408
+ function addHostDispatches(entry, result) {
409
+ if (typeof entry.id === "string") {
410
+ const tool = dispatchTool(entry.tool);
411
+ if (tool !== null) {
412
+ result.callIds.add(entry.id);
413
+ result.nativeCalls.push({
414
+ tool,
415
+ status: typeof entry.status === "string" ? entry.status : "unknown",
416
+ input: entry.input,
417
+ callId: entry.id,
418
+ });
419
+ }
420
+ if (entry.tool === "execute")
421
+ result.callIds.add(entry.id);
422
+ }
423
+ if (entry.tool !== "execute" || !isRecord(entry.metadata))
424
+ return;
425
+ const envelope = isRecord(entry.metadata.metadata)
426
+ ? entry.metadata.metadata
427
+ : entry.metadata;
428
+ const calls = Array.isArray(envelope.toolCalls) ? envelope.toolCalls : [];
429
+ appendDispatches(calls, result.dispatches, typeof entry.id === "string" ? entry.id : null);
430
+ }
431
+ /**
432
+ * Append the docs dispatches one execute result recorded.
433
+ * @param calls The result's recorded tool calls.
434
+ * @param dispatches The accumulating dispatches.
435
+ */
436
+ function appendDispatches(calls, dispatches, parentCallId) {
437
+ for (const call of calls) {
438
+ if (!isRecord(call))
439
+ continue;
440
+ const tool = dispatchTool(call.tool);
441
+ if (tool === null)
442
+ continue;
443
+ dispatches.push({
444
+ tool,
445
+ status: typeof call.status === "string" ? call.status : "unknown",
446
+ input: call.input,
447
+ callId: parentCallId,
448
+ });
449
+ }
450
+ }
451
+ /**
452
+ * Check a captured answer against the producer's `meta.answerHash`.
453
+ *
454
+ * The harness hashes the exact final-answer text as bytes (`sha256(text)`),
455
+ * so the analyser must hash the recorded `final.md` bytes, never a normalised
456
+ * rendering. A missing hash leaves the answer unbound rather than trusted.
457
+ * @param answer The recorded answer bytes.
458
+ * @param answerHash The producer's recorded hash, when present.
459
+ * @param settings The optional binding settings.
460
+ * @param settings.allowUnbound Explicit read-only coverage for legacy evidence
461
+ * written before the producer recorded a hash.
462
+ * @returns A problem, or null when the answer is bound.
463
+ */
464
+ export function answerIdentityProblem(answer, answerHash, settings = {}) {
465
+ if (typeof answerHash !== "string" || answerHash === "") {
466
+ return settings.allowUnbound
467
+ ? null
468
+ : "no recorded answer hash; the answer cannot be bound to its trial";
469
+ }
470
+ const actual = createHash("sha256").update(answer, "utf8").digest("hex");
471
+ if (actual !== answerHash) {
472
+ return "recorded answer does not match meta.answerHash; the answer was altered after capture";
473
+ }
474
+ return null;
475
+ }