@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
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Mark Standen
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,264 @@
1
- # Temporary Holding Version
1
+ # OpenCode Test Container
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ An isolated test sandbox for OpenCode plugins, skills and agent instructions.
4
+
5
+ `otc` prepares an immutable campaign, freezes every input identity, runs each
6
+ scheduled slot in a Podman sandbox through the real OpenCode CLI, executes the
7
+ product's primary check, assesses the captured evidence deterministically,
8
+ optionally dispatches an arm-blind judge, closes the ledger and renders a
9
+ report. The judge is the only model call OTC makes; a campaign either uses a
10
+ scripted loopback provider (offline) or the host provider route the runtime
11
+ declares (potentially paid).
12
+
13
+ ```sh
14
+ npm install --global @markstanden/opencode-test-container
15
+ otc help
16
+ ```
17
+
18
+ The installed binary is emitted JavaScript and carries every runtime asset; it
19
+ does not need a checkout, `tsx` or Bun.
20
+
21
+ ## The model
22
+
23
+ | Term | Meaning |
24
+ | ---------- | ------------------------------------------------------------ |
25
+ | suite | reusable, discriminated case definitions and assessment kind |
26
+ | case | one prompt/context and its expected behaviour |
27
+ | campaign | one frozen scheduled execution |
28
+ | arm | one treatment configuration (subjects + non-secret options) |
29
+ | slot | one case × arm × repetition |
30
+ | trial | one execution of a slot |
31
+ | assessment | deterministic or model-based evaluation |
32
+ | report | derived output, outside immutable evidence |
33
+
34
+ A minimal skill-only campaign needs no docs plugin, no knowledge lake and no
35
+ judge. See [`examples/skills`](examples/skills).
36
+
37
+ ## Quick start (offline, no provider call)
38
+
39
+ ```sh
40
+ otc validate --campaign examples/skills/campaign.json
41
+ otc prepare --campaign examples/skills/campaign.json --root /tmp/otc-run
42
+ otc freeze --root /tmp/otc-run
43
+ otc run --root /tmp/otc-run --dry-run # inspect the plan, no dispatch
44
+ otc run --root /tmp/otc-run # real Podman + OpenCode
45
+ otc judge --root /tmp/otc-run # optional blind judge, if declared
46
+ otc assess --root /tmp/otc-run # deterministic, zero judge calls
47
+ otc close --root /tmp/otc-run
48
+ otc analyse --root /tmp/otc-run --out /tmp/otc-report
49
+ otc status --root /tmp/otc-run --json
50
+ ```
51
+
52
+ `status` is read-only: it does not build images, probe providers, repair files
53
+ or dispatch. Its next actions are derived from the manifest and checked
54
+ evidence, not from baked-in campaign constants.
55
+
56
+ ## Suite format
57
+
58
+ Versioned JSON (`schemaVersion: 1`). `assessment` selects one trusted
59
+ evaluator; cases are discriminated.
60
+
61
+ ```jsonc
62
+ {
63
+ "schemaVersion": 1,
64
+ "suiteId": "skills-basic",
65
+ "assessment": "skill-activation",
66
+ "cases": [
67
+ {
68
+ "caseId": "positive-instrument",
69
+ "kind": "skill-trigger",
70
+ "targetSkillId": "otel-helper",
71
+ "expected": "activation",
72
+ "prompt": "Instrument the checkout service with OpenTelemetry spans.",
73
+ },
74
+ {
75
+ "caseId": "negative-near-miss",
76
+ "kind": "skill-trigger",
77
+ "targetSkillId": "otel-helper",
78
+ "expected": "no-activation",
79
+ "prompt": "Add a console log; no telemetry is needed.",
80
+ },
81
+ ],
82
+ }
83
+ ```
84
+
85
+ Docs cases use `kind: "docs-task"`, `expectedDocs`, an optional `criterion`
86
+ and deterministic `guardrails`. An optional `primaryCheck` names a
87
+ product-supplied checker directory and script; it is staged read-only and run
88
+ in its own no-network container after the trial, never mounted into the trial.
89
+ `truth` is arm-blind judge context and never enters a workspace. Docs evidence
90
+ is assessed from the plugin's signed operation journal, never from a transcript
91
+ claim.
92
+
93
+ ## Campaign format
94
+
95
+ ```jsonc
96
+ {
97
+ "schemaVersion": 1,
98
+ "campaignId": "skills-local",
99
+ "suite": { "path": "suite.json", "sha256": "<optional pinned hash>" },
100
+ "subjects": [
101
+ {
102
+ "armId": "skill-only",
103
+ "skills": [{ "id": "otel-helper", "path": "skills/otel-helper" }],
104
+ "plugins": [{ "id": "docs", "path": "/abs/path/to/plugin" }],
105
+ "instructions": [
106
+ { "id": "overlay", "path": "AGENTS.md", "as": "AGENTS.md" },
107
+ ],
108
+ },
109
+ ],
110
+ "schedule": {
111
+ "kind": "explicit",
112
+ "slots": [
113
+ {
114
+ "slotId": "s1",
115
+ "caseId": "positive-instrument",
116
+ "armId": "skill-only",
117
+ "repetitionId": "r0",
118
+ },
119
+ ],
120
+ },
121
+ "runtime": {
122
+ "image": "localhost/otc-sandbox:2.2.6",
123
+ "imageId": "sha256:…",
124
+ "opencodeVersion": "2.2.6",
125
+ "provider": {
126
+ "route": "scripted",
127
+ "model": "vendor/model",
128
+ "overlay": {},
129
+ },
130
+ "timeoutMs": 180000,
131
+ "network": "host",
132
+ "passthroughEnv": ["PROVIDER_API_KEY"],
133
+ },
134
+ "budgets": { "trials": 1, "judgeAttempts": 2 },
135
+ "assessment": { "kind": "skill-activation" },
136
+ }
137
+ ```
138
+
139
+ A docs campaign may declare a judge and a narrow recovery registration:
140
+
141
+ ```jsonc
142
+ "budgets": { "trials": 4, "judgeAttempts": 8 },
143
+ "assessment": {
144
+ "kind": "docs-recording",
145
+ "judge": { "model": "provider/model", "timeoutMs": 180000 },
146
+ "recovery": {
147
+ "version": 1,
148
+ "eligibleFailureClasses": ["json-syntax"],
149
+ "requiresCompletionEvidence": true,
150
+ "maxRecoveriesPerSlot": 1,
151
+ "recoveryCeiling": 4,
152
+ "totalJudgeAttempts": 8,
153
+ "providerRequestLimit": null
154
+ }
155
+ }
156
+ ```
157
+
158
+ `judgeAttempts` counts dispatches, not HTTP requests. Each attempt is claimed
159
+ durably before it starts; a reserved or spawned claim keeps consuming budget.
160
+ "none" in `network` is the only true no-egress mode; without `judgeAttempts`
161
+ a campaign makes no model call of its own.
162
+
163
+ Subject sources may be local paths; they are copied into
164
+ `prepare/subjects/<armId>` before dispatch. Relative paths resolve against the
165
+ campaign document. Symlinks escaping a source root, path collisions and
166
+ unreadable inventories are refused. Only the staged payload is mounted — never
167
+ the whole OTC repository, the complete subject repository, other cases, truth
168
+ or rubric data.
169
+
170
+ ## Assessment
171
+
172
+ - `skill-activation` is deterministic and makes zero judge calls. It records
173
+ the requested target, successful target loads, failed target loads, other
174
+ skill loads and no-invocation separately. Capture validity is separate from
175
+ the criterion: a complete run with no activation is an observed negative; a
176
+ missing/truncated capture or a failed target load is unknown/invalid, never a
177
+ passing negative. A non-skill route to the target body is contamination.
178
+ - `docs-recording` verifies the plugin's HMAC-signed journal against the host
179
+ tool trail, then checks the written bytes. A refused write retains zero
180
+ recording credit; attempts are counted separately.
181
+ - The full recorded-evidence analyser is also available offline:
182
+
183
+ ```sh
184
+ otc replay --manifest <campaign.json> \
185
+ --evidence-root <recorded-evidence-root> --out <fresh-report-dir>
186
+ ```
187
+
188
+ It validates the declared campaign, loads explicit evidence states, and
189
+ produces accounting, utility/opportunity counts, retrieval measures,
190
+ preservation, guardrails, pairing, bounds, screening, decision and stable
191
+ reports. It makes no model call. `--allow-legacy-unbound` is the explicit
192
+ legacy-answer escape; `--rerender` replaces an existing output.
193
+
194
+ - An optional docs judge is arm-blind by construction: the packet contains the
195
+ task, standing permission, authored truth, before/after lake documents,
196
+ changed paths, primary-check output, verified docs operations and the final
197
+ answer. It contains no arm label, campaign identity, evidence root or prior
198
+ verdict. The established parser and validator classify a usable judgement or
199
+ an explicit `json-syntax`, `schema`, `semantic`, `empty-or-truncated` or
200
+ `transport-or-process` failure. Only `json-syntax` is recoverable, and only
201
+ once, from identical packet bytes under a registered policy and durable
202
+ ceilings.
203
+
204
+ Model judging is not a deterministic pass/fail substitute: the deterministic
205
+ assessment remains the criterion, and the judge records the documentation
206
+ outcome and utility. A campaign that declares a judge is not refused.
207
+
208
+ A selection is accepted only through one checked reader shared by judge,
209
+ status, closure and reporting: attempt set, packet bytes, policy hash,
210
+ judgement hash, outcome/utility and the judged mutation state are re-derived.
211
+ Slot and attempt packet bytes are hashed and compared with the registered
212
+ identity and with the packet reconstructed from current evidence.
213
+ Judge and recovery claims live under `campaign root/evidence`; a recovery
214
+ settles both, and an interrupted claim stays visible and blocks closure. The
215
+ judge's standing-permission block comes from the pristine prepared workspace,
216
+ never the trial's live `AGENTS.md`. Before dispatch, the executing artifact
217
+ hash is compared with the freeze, derived from the executing module's own
218
+ `src`/`dist` tree, so changed code under the same package version is refused.
219
+
220
+ ## Sandbox limits
221
+
222
+ The container is read-only apart from the trial workspace, `/tmp` and (for
223
+ docs cases) the journal directory. Capabilities are dropped and
224
+ `no-new-privileges` is set. With `network: "host"` the trial shares the host
225
+ network so a loopback provider proxy is reachable; that is **not** network
226
+ isolation and OTC does not describe it as provider-only egress. Credentials
227
+ travel through a 0600 env file, never argv, logs or receipts.
228
+
229
+ A product primary checker runs in a separate `network: "none"` container with
230
+ only the completed workspace, the pristine workspace and the staged checker
231
+ mounted read-only. The judge runs in another neutral container with no arm
232
+ subjects, no campaign manifest and no evidence root.
233
+
234
+ ## Docs evaluator capability
235
+
236
+ `src/evaluators/docs` carries the established docs analysis semantics, not a
237
+ narrower recording-only criterion: recording credit, utility and opportunity
238
+ counts, retrieval, preservation, guardrails, pairing/bounds, screening,
239
+ decision and reporting. `docs/capability-matrix.md` maps every capability to
240
+ its original implementation, tests, parity evidence and remaining owner.
241
+
242
+ ## Historical compatibility
243
+
244
+ `src/legacy/prod014.ts` translates the frozen prod014 schedule and manifest
245
+ into the new vocabulary without renaming a stamp or scenario id. The old
246
+ campaign roots are not resumed by this engine; use a fresh root and keep the
247
+ original for provenance.
248
+
249
+ ## Development
250
+
251
+ See [AGENTS.md](AGENTS.md) and [TESTING.md](TESTING.md). The offline real
252
+ rehearsal is separate from `npm test`:
253
+
254
+ ```sh
255
+ npm run rehearse:offline
256
+ npm run rehearse:fresh-docs
257
+ npm run rehearse:installed
258
+ ```
259
+
260
+ The second rehearsal exercises the complete fresh docs lifecycle: primary
261
+ check, blind judge, one recovery, an exhausted budget, checked selection,
262
+ closure, analysis and post-close refusal. The third packs and installs the
263
+ tarball and repeats the same lifecycle through the installed binary; see
264
+ [TESTING.md](TESTING.md).
@@ -0,0 +1,24 @@
1
+ # OTC sandbox image.
2
+ #
3
+ # Both inputs are explicit build arguments: the Node base and the OpenCode CLI
4
+ # version. The recipe does not float. A campaign freezes the resolved image ID
5
+ # and the CLI version it contains before any dispatch.
6
+ FROM ${NODE_BASE}
7
+
8
+ ARG HOST_UID=1000
9
+ ARG HOST_GID=1000
10
+ ARG OPENCODE_VERSION
11
+
12
+ ENV DEBIAN_FRONTEND=noninteractive
13
+
14
+ RUN apt-get update \
15
+ && apt-get install -y --no-install-recommends ca-certificates git \
16
+ && rm -rf /var/lib/apt/lists/* \
17
+ && test -n "${OPENCODE_VERSION}" \
18
+ && npm install -g --silent "@opencode/cli@${OPENCODE_VERSION}" \
19
+ && mkdir -p /workspace /tmp/agent-home \
20
+ && chown -R "${HOST_UID}:${HOST_GID}" /workspace /tmp/agent-home
21
+
22
+ USER ${HOST_UID}:${HOST_GID}
23
+ WORKDIR /workspace
24
+ ENV HOME=/tmp/agent-home
@@ -0,0 +1,29 @@
1
+ # OTC container
2
+
3
+ `Containerfile` builds the sandbox image. Both floating inputs are explicit:
4
+
5
+ ```sh
6
+ podman build \
7
+ --build-arg NODE_BASE=docker.io/library/node:26.13.0-slim \
8
+ --build-arg OPENCODE_VERSION=2.2.6 \
9
+ --build-arg HOST_UID="$(id -u)" \
10
+ --build-arg HOST_GID="$(id -g)" \
11
+ -t localhost/otc-sandbox:2.2.6 .
12
+ ```
13
+
14
+ `otc freeze` resolves the tag to an immutable image ID and proves the image
15
+ contains the declared OpenCode version. Campaigns then dispatch the ID, never
16
+ a mutable tag.
17
+
18
+ ## Isolation, stated honestly
19
+
20
+ The container has a read-only root filesystem, a bounded `/tmp` tmpfs, dropped
21
+ capabilities, no-new-privileges and `--userns=keep-id`. Only the trial
22
+ workspace, the read-only config home and the read-only subject payload are
23
+ mounted.
24
+
25
+ It shares the host network when `runtime.network` is `host`, because the
26
+ existing provider route lives on the host loopback proxy. That is **not**
27
+ provider-only egress and OTC does not claim it is. Use `network: "none"` when
28
+ the trial needs no provider, or add network enforcement before claiming a
29
+ provider-only boundary.
@@ -0,0 +1,90 @@
1
+ /** One deduplicated tool call decoded from the CLI event stream. */
2
+ export interface ToolCall {
3
+ /** The id value. */
4
+ id: string;
5
+ /** The tool value. */
6
+ tool: string | null;
7
+ /** The status value. */
8
+ status: string | null;
9
+ /** The input value. */
10
+ input: unknown;
11
+ /** The output value. */
12
+ output: string | null;
13
+ /** The metadata value. */
14
+ metadata: unknown;
15
+ }
16
+ /** Token and cost usage folded from `step_finish` events. */
17
+ export interface Usage {
18
+ /** The input tokens value. */
19
+ inputTokens: number;
20
+ /** The output tokens value. */
21
+ outputTokens: number;
22
+ /** The reasoning tokens value. */
23
+ reasoningTokens: number;
24
+ /** The cache read tokens value. */
25
+ cacheReadTokens: number;
26
+ /** The cache write tokens value. */
27
+ cacheWriteTokens: number;
28
+ /** The cost usd value. */
29
+ costUsd: number;
30
+ /** Whether every usage value came from a recorded step-finish event. */
31
+ reported: boolean;
32
+ /** The steps value. */
33
+ steps: number;
34
+ }
35
+ /** A decoded trial event stream. */
36
+ export interface DecodedTrail {
37
+ /** Parsed events. */
38
+ events: number;
39
+ /** Non-empty lines that were not JSON objects. */
40
+ parseFailures: number;
41
+ /** The concatenated answer text, newline-terminated. */
42
+ text: string;
43
+ /** Number of text parts observed. */
44
+ textParts: number;
45
+ /** The last part kind observed, or null. */
46
+ lastPartKind: "text" | "tool" | null;
47
+ /** Deduplicated calls in first-seen order. */
48
+ calls: ToolCall[];
49
+ /** Session identities observed. */
50
+ sessions: string[];
51
+ /** Message identities observed. */
52
+ messages: string[];
53
+ /** Known usage, with `reported` false when no step-finish event existed. */
54
+ usage: Usage;
55
+ }
56
+ /**
57
+ * Decode `opencode run --format json` JSONL into answer, calls, identity and
58
+ * usage. Malformed lines are counted, never treated as an empty trail.
59
+ * @param stdout Raw CLI stdout.
60
+ * @returns The decoded trail.
61
+ */
62
+ export declare function decodeTrail(stdout: string): DecodedTrail;
63
+ /** Whether a decoded stream is a complete, readable observation. */
64
+ export declare function captureProblems(trail: DecodedTrail): string[];
65
+ /** One skill tool call observed in a trail. */
66
+ export interface SkillCall {
67
+ /** The call id value. */
68
+ callId: string;
69
+ /** The requested skill identity, when the input names one. */
70
+ requestedId: string | null;
71
+ /** The status value. */
72
+ status: string | null;
73
+ /** Whether the call completed with a non-empty skill body. */
74
+ loaded: boolean;
75
+ }
76
+ /**
77
+ * Every skill invocation in a trail, including other skills and failed loads.
78
+ * @param trail The decoded trial.
79
+ * @returns Skill calls in first-seen order.
80
+ */
81
+ export declare function skillCalls(trail: DecodedTrail): SkillCall[];
82
+ /**
83
+ * Whether a non-skill tool route names the target skill body. Activation
84
+ * through the skill tool is the criterion; a `read`, `grep`, `glob` or
85
+ * `execute` route is contamination evidence, not a successful activation.
86
+ * @param trail The decoded trial.
87
+ * @param targetSkillId The target identity.
88
+ * @returns The tool names that touched the target body, deduplicated.
89
+ */
90
+ export declare function bodyRoutes(trail: DecodedTrail, targetSkillId: string): string[];
@@ -0,0 +1,213 @@
1
+ import { isRecord } from "../../contracts/validate.js";
2
+ /** The empty usage accumulator. */
3
+ function emptyUsage() {
4
+ return {
5
+ inputTokens: 0,
6
+ outputTokens: 0,
7
+ reasoningTokens: 0,
8
+ cacheReadTokens: 0,
9
+ cacheWriteTokens: 0,
10
+ costUsd: 0,
11
+ reported: false,
12
+ steps: 0,
13
+ };
14
+ }
15
+ /** Parse one JSONL line, or undefined when it is not an object. */
16
+ function parseLine(line) {
17
+ try {
18
+ const value = JSON.parse(line);
19
+ return isRecord(value) ? value : undefined;
20
+ }
21
+ catch {
22
+ return undefined;
23
+ }
24
+ }
25
+ /** Fold one event's identity ids into the trail. */
26
+ function foldIdentity(event, sessions, messages) {
27
+ if (typeof event.sessionID === "string")
28
+ sessions.add(event.sessionID);
29
+ if (typeof event.messageID === "string")
30
+ messages.add(event.messageID);
31
+ if (!isRecord(event.part))
32
+ return;
33
+ if (typeof event.part.sessionID === "string") {
34
+ sessions.add(event.part.sessionID);
35
+ }
36
+ if (typeof event.part.messageID === "string") {
37
+ messages.add(event.part.messageID);
38
+ }
39
+ }
40
+ /** The first defined value, or null. */
41
+ function firstDefined(...values) {
42
+ for (const value of values) {
43
+ if (value !== undefined)
44
+ return value;
45
+ }
46
+ return null;
47
+ }
48
+ /** Merge one latest-state tool part over its previous call. */
49
+ function mergedToolCall(previous, part, id) {
50
+ const state = isRecord(part.state)
51
+ ? part.state
52
+ : {};
53
+ const prev = previous ?? {
54
+ id,
55
+ tool: null,
56
+ status: null,
57
+ input: null,
58
+ output: null,
59
+ metadata: null,
60
+ };
61
+ return {
62
+ id,
63
+ tool: firstDefined(part.tool, prev.tool),
64
+ status: firstDefined(state.status, prev.status),
65
+ input: firstDefined(state.input, prev.input),
66
+ output: firstDefined(state.output, prev.output),
67
+ metadata: firstDefined(state.metadata, prev.metadata),
68
+ };
69
+ }
70
+ /** Fold one tool part over the previous call state. */
71
+ function foldToolPart(part, calls, unkeyed) {
72
+ const id = typeof part.id === "string" && part.id !== ""
73
+ ? part.id
74
+ : `unkeyed-${++unkeyed.count}`;
75
+ calls.set(id, mergedToolCall(calls.get(id), part, id));
76
+ }
77
+ /** Fold one step-finish event's usage into the accumulator. */
78
+ function foldUsage(event, usage) {
79
+ const part = isRecord(event.part) ? event.part : undefined;
80
+ if (part?.type !== "step-finish" || !isRecord(part.tokens))
81
+ return;
82
+ const tokens = part.tokens;
83
+ const token = (field) => typeof tokens[field] === "number" ? tokens[field] : 0;
84
+ const cache = isRecord(tokens.cache) ? tokens.cache : {};
85
+ usage.inputTokens += token("input");
86
+ usage.outputTokens += token("output");
87
+ usage.reasoningTokens += token("reasoning");
88
+ usage.cacheReadTokens += typeof cache.read === "number" ? cache.read : 0;
89
+ usage.cacheWriteTokens += typeof cache.write === "number" ? cache.write : 0;
90
+ usage.costUsd += typeof part.cost === "number" ? part.cost : 0;
91
+ usage.reported = true;
92
+ usage.steps += 1;
93
+ }
94
+ /**
95
+ * Decode `opencode run --format json` JSONL into answer, calls, identity and
96
+ * usage. Malformed lines are counted, never treated as an empty trail.
97
+ * @param stdout Raw CLI stdout.
98
+ * @returns The decoded trail.
99
+ */
100
+ export function decodeTrail(stdout) {
101
+ const texts = [];
102
+ const calls = new Map();
103
+ const sessions = new Set();
104
+ const messages = new Set();
105
+ const usage = emptyUsage();
106
+ const unkeyed = { count: 0 };
107
+ let events = 0;
108
+ let parseFailures = 0;
109
+ let textParts = 0;
110
+ let lastPartKind = null;
111
+ for (const line of stdout.split("\n").filter((entry) => entry !== "")) {
112
+ const event = parseLine(line);
113
+ if (event === undefined) {
114
+ parseFailures += 1;
115
+ continue;
116
+ }
117
+ events += 1;
118
+ foldIdentity(event, sessions, messages);
119
+ foldUsage(event, usage);
120
+ const part = isRecord(event.part) ? event.part : event;
121
+ if (part.type === "text" && typeof part.text === "string") {
122
+ texts.push(part.text);
123
+ textParts += 1;
124
+ lastPartKind = "text";
125
+ continue;
126
+ }
127
+ if (part.type === "tool") {
128
+ foldToolPart(part, calls, unkeyed);
129
+ lastPartKind = "tool";
130
+ }
131
+ }
132
+ return {
133
+ events,
134
+ parseFailures,
135
+ text: `${texts.join("\n").trim()}\n`,
136
+ textParts,
137
+ lastPartKind,
138
+ calls: [...calls.values()],
139
+ sessions: [...sessions],
140
+ messages: [...messages],
141
+ usage,
142
+ };
143
+ }
144
+ /** Whether a decoded stream is a complete, readable observation. */
145
+ export function captureProblems(trail) {
146
+ const problems = [];
147
+ if (trail.events === 0)
148
+ problems.push("the trial emitted no events");
149
+ if (trail.parseFailures > 0) {
150
+ problems.push(`${trail.parseFailures} event line(s) were not readable JSON`);
151
+ }
152
+ if (trail.textParts === 0) {
153
+ problems.push("the trial emitted no answer text");
154
+ }
155
+ if (trail.lastPartKind !== "text") {
156
+ problems.push("the trial stream did not end on an answer");
157
+ }
158
+ return problems;
159
+ }
160
+ /** Fold the input/output of one skill call into its observation. */
161
+ function skillCallOf(call) {
162
+ if (call.tool !== "skill")
163
+ return null;
164
+ const input = isRecord(call.input) ? call.input : {};
165
+ const requested = typeof input.id === "string"
166
+ ? input.id
167
+ : typeof input.name === "string"
168
+ ? input.name
169
+ : null;
170
+ const output = call.output?.trim() ?? "";
171
+ return {
172
+ callId: call.id,
173
+ requestedId: requested,
174
+ status: call.status,
175
+ loaded: call.status === "completed" &&
176
+ output !== "" &&
177
+ !output.startsWith("Rejected:"),
178
+ };
179
+ }
180
+ /**
181
+ * Every skill invocation in a trail, including other skills and failed loads.
182
+ * @param trail The decoded trial.
183
+ * @returns Skill calls in first-seen order.
184
+ */
185
+ export function skillCalls(trail) {
186
+ return trail.calls
187
+ .map(skillCallOf)
188
+ .filter((call) => call !== null);
189
+ }
190
+ /**
191
+ * Whether a non-skill tool route names the target skill body. Activation
192
+ * through the skill tool is the criterion; a `read`, `grep`, `glob` or
193
+ * `execute` route is contamination evidence, not a successful activation.
194
+ * @param trail The decoded trial.
195
+ * @param targetSkillId The target identity.
196
+ * @returns The tool names that touched the target body, deduplicated.
197
+ */
198
+ export function bodyRoutes(trail, targetSkillId) {
199
+ const routes = new Set();
200
+ const needles = [
201
+ `skills/${targetSkillId}/SKILL.md`,
202
+ `skill://${targetSkillId}`,
203
+ ];
204
+ for (const call of trail.calls) {
205
+ if (call.tool === "skill")
206
+ continue;
207
+ const text = JSON.stringify(call.input ?? null);
208
+ if (needles.some((needle) => text.includes(needle))) {
209
+ routes.add(call.tool ?? "unknown");
210
+ }
211
+ }
212
+ return [...routes].sort();
213
+ }