@workos/quickstudy 0.2.1 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -95,6 +95,10 @@ quickstudy report <run-id>
95
95
  writes NOTES.md, so this toy check passes; against real evals every check
96
96
  fails, which is exactly what a negative control should do. Completing the
97
97
  run → score → persist loop is what it proves.
98
+ The positive control is the built-in `oracle` agent: give an eval a
99
+ `solution/` reference solution and `quickstudy validate --oracle` checks that
100
+ it scores 1.0 before any paid run (see
101
+ [Writing an eval](docs/writing-an-eval.md#prove-the-scorer-with-a-reference-solution)).
98
102
 
99
103
  Real measurements come from a benchmark repository built on quickstudy: an
100
104
  `evals/` root of real integration tasks (tiny HTTP apps under `local/`) and
@@ -231,6 +235,27 @@ quickstudy export <run-id> --out ./site
231
235
  npx gh-pages -d site # or push ./site to any static host
232
236
  ```
233
237
 
238
+ ### Embedding the viewers
239
+
240
+ A host that renders exported bundles in its own UI can use this UI's transcript and
241
+ diff viewers directly instead of copying them:
242
+
243
+ ```tsx
244
+ import { DiffViewer, TranscriptViewer } from "@workos/quickstudy/ui/viewers";
245
+ import "@workos/quickstudy/ui/viewers.css";
246
+
247
+ // Fetch data/runs/<run>/attempts/<id>.transcript.txt yourself (the bundle lazy-loads it
248
+ // the same way), then render it; `lines` highlights and scrolls to a cited range.
249
+ <div className="my-theme"><TranscriptViewer text={text} lines={[21, 60]} /></div>
250
+ ```
251
+
252
+ The components import React only (an optional peer dependency). Every rule in
253
+ `viewers.css` is scoped under the components' root class `.quickstudy-viewer`, so importing
254
+ it restyles nothing else in the host. It reads the theme through custom properties the host sets on a wrapping element: `--surface`, `--bg`,
255
+ `--line`, `--muted`, `--accent`, `--green`, `--amber`, `--amber-bg`, `--green-bg`,
256
+ `--red-bg`, `--gray-bg` and `--mono`. Turn parsing, roles, collapsing and the `L<n>`
257
+ anchors are exactly what this UI shows, so evidence links carry over unchanged.
258
+
234
259
  ## Development
235
260
 
236
261
  Run the CLI from source as `bun src/cli.ts <cmd>` (what `quickstudy` is,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@workos/quickstudy",
3
- "version": "0.2.1",
3
+ "version": "0.4.0",
4
4
  "description": "An open-source, company-agnostic eval harness for measuring whether coding agents can complete real integration tasks, across an agent x feature x framework x surface matrix.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -9,7 +9,9 @@
9
9
  },
10
10
  "exports": {
11
11
  ".": "./src/index.ts",
12
- "./package.json": "./package.json"
12
+ "./package.json": "./package.json",
13
+ "./ui/viewers": "./ui/src/viewers.ts",
14
+ "./ui/viewers.css": "./ui/src/components/viewers.css"
13
15
  },
14
16
  "files": [
15
17
  "src",
@@ -17,7 +19,11 @@
17
19
  "images",
18
20
  "examples",
19
21
  "README.md",
20
- "LICENSE"
22
+ "LICENSE",
23
+ "ui/src/viewers.ts",
24
+ "ui/src/components/TranscriptViewer.tsx",
25
+ "ui/src/components/DiffViewer.tsx",
26
+ "ui/src/components/viewers.css"
21
27
  ],
22
28
  "engines": {
23
29
  "bun": ">=1.1"
@@ -49,5 +55,13 @@
49
55
  "react-dom": "^19.3.0",
50
56
  "typescript": "^7.0.2",
51
57
  "vite": "^8.3.0"
58
+ },
59
+ "peerDependencies": {
60
+ "react": ">=18"
61
+ },
62
+ "peerDependenciesMeta": {
63
+ "react": {
64
+ "optional": true
65
+ }
52
66
  }
53
67
  }
@@ -0,0 +1,67 @@
1
+ /**
2
+ * The oracle adapter: applies the eval's reference solution and does nothing
3
+ * else.
4
+ *
5
+ * A bench is only trustworthy when a reference solution scores 1.0 on every
6
+ * eval (and a no-op — the echo adapter — scores 0). The oracle declares
7
+ * `startingState: "solution"`, so the runner's shared workspace prep overlays
8
+ * `solution/` on `local/` before the adapter runs; both the host and the
9
+ * container variant are then no-ops. No model runs: turns and cost are true
10
+ * zeros, token counts are unavailable (null).
11
+ */
12
+
13
+ import { cp } from "node:fs/promises";
14
+ import type { AgentAdapter, AttemptContext, AttemptMetrics, AttemptOutcome, ContainerAgentAdapter } from "./types.ts";
15
+
16
+ const ORACLE_IDENTITY = {
17
+ provider: "local", model: "oracle-v1", reasoning: "none", cliVersion: "built-in",
18
+ surfacePolicies: { docs: "no-network", mcp: "no-network", cli: "no-network" },
19
+ nativeWebControl: "controlled" as const,
20
+ reproducible: true,
21
+ };
22
+
23
+ function oracleMetrics(): AttemptMetrics {
24
+ return { tokensIn: null, tokensOut: null, turns: 0, costUsd: 0 };
25
+ }
26
+
27
+ /** Host variant: the prepared fixture already carries the solution overlay. */
28
+ export class OracleAdapter implements AgentAdapter {
29
+ readonly name = "oracle";
30
+ readonly identity = ORACLE_IDENTITY;
31
+ readonly startingState = "solution" as const;
32
+
33
+ async runAttempt(ctx: AttemptContext): Promise<AttemptOutcome> {
34
+ await cp(ctx.fixtureDir, ctx.workspaceDir, { recursive: true });
35
+ return {
36
+ transcript: [{ role: "assistant", content: "oracle: applied the reference solution", at: new Date().toISOString() }],
37
+ diff: "",
38
+ metrics: oracleMetrics(),
39
+ };
40
+ }
41
+ }
42
+
43
+ /**
44
+ * Container variant: the runner `docker cp`s the overlaid source into
45
+ * /workspace, so the agent command is a no-op. Experiments using it must not
46
+ * set `runtime.command` (that bypasses adapter resolution).
47
+ */
48
+ export class OracleContainerAdapter implements ContainerAgentAdapter {
49
+ readonly kind = "container" as const;
50
+ readonly name = "oracle";
51
+ readonly identity = ORACLE_IDENTITY;
52
+ readonly startingState = "solution" as const;
53
+ readonly requiredHostEnv: readonly string[] = [];
54
+
55
+ setupFiles(): Record<string, string> {
56
+ return {};
57
+ }
58
+
59
+ command(): string[] {
60
+ return ["true"];
61
+ }
62
+
63
+ /** The no-op command prints nothing; input is ignored. */
64
+ parseStream(_raw: string): AttemptMetrics {
65
+ return oracleMetrics();
66
+ }
67
+ }
@@ -73,10 +73,21 @@ export interface AttemptOutcome {
73
73
  metrics: AttemptMetrics;
74
74
  }
75
75
 
76
+ /**
77
+ * Which tree the runner prepares as the attempt's starting workspace:
78
+ * `"fixture"` (the default) copies the eval's `local/`; `"solution"` copies
79
+ * `local/` and then overlays the eval's `solution/` reference solution (the
80
+ * built-in oracle adapter). A plan pairing a `"solution"` adapter with an
81
+ * eval that has no `solution/` is refused at preflight.
82
+ */
83
+ export type StartingState = "fixture" | "solution";
84
+
76
85
  export interface AgentAdapter {
77
86
  /** The adapter name experiments select via `agent.adapter` (e.g. "echo"). */
78
87
  name: string;
79
88
  identity?: AdapterIdentityConfig;
89
+ /** Starting workspace the runner prepares for this adapter (default "fixture"). */
90
+ startingState?: StartingState;
80
91
  runAttempt(ctx: AttemptContext): Promise<AttemptOutcome>;
81
92
  }
82
93
 
@@ -115,6 +126,8 @@ export interface ContainerAgentAdapter {
115
126
  /** The adapter name experiments select via `agent.adapter` (e.g. "claude", "codex"). */
116
127
  name: string;
117
128
  identity?: AdapterIdentityConfig;
129
+ /** Starting workspace the runner prepares for this adapter (default "fixture"). */
130
+ startingState?: StartingState;
118
131
  /** Command used during preflight to record the executable's exact version. */
119
132
  versionCommand?: readonly string[];
120
133
  /**
@@ -9,7 +9,7 @@ export const EMBEDDED_BUILD_INFO: {
9
9
  // (the `x-release-please-version` marker anchors the replacement). It is
10
10
  // restored as-is after a binary build (scripts/build-binary.ts), so the
11
11
  // committed source `--version` stays in lockstep with package.json.
12
- version: "0.2.1", // x-release-please-version
12
+ version: "0.4.0", // x-release-please-version
13
13
  revision: null,
14
14
  dirty: null,
15
15
  sourceHash: null,
package/src/cli.ts CHANGED
@@ -59,6 +59,7 @@ import { compareGroups } from "./experiments/groups.ts";
59
59
  import { buildRunManifest } from "./manifest.ts";
60
60
  import { filterEvals, planRun } from "./plan.ts";
61
61
  import { executeRunV3 } from "./runner/execute.ts";
62
+ import { formatOracleOutcome, validateOracle } from "./runner/oracle-validate.ts";
62
63
  import { collectProviderKeys, InvalidProviderKeyError, MissingProviderKeyError } from "./secrets.ts";
63
64
  import { serveSite } from "./serve.ts";
64
65
  import { UI_INDEX_HTML_BASE64 } from "./ui-bundle.generated.ts";
@@ -71,6 +72,7 @@ Usage:
71
72
  quickstudy validate Validate the evals and experiments roots
72
73
  [--evals-root <dir>] Evals root (default: evals)
73
74
  [--experiments-root <dir>] Experiments root (default: experiments)
75
+ [--oracle] Score each eval's solution/ reference solution (expects 1.0)
74
76
  quickstudy run [--eval <id>...] Execute evals against experiments
75
77
  [--experiment <id>...] Omit either selector to include every discovered item
76
78
  [--evals-root <dir>] Evals root (default: evals)
@@ -104,7 +106,7 @@ Usage:
104
106
  `;
105
107
 
106
108
  const COMMAND_HELP: Record<string, string> = {
107
- validate: `Usage: quickstudy validate [--evals-root <dir>] [--experiments-root <dir>]\n\nDiscover and validate the evals root (frontmatter, scorer default exports)\nand the experiments root (defineExperiment shapes, comparison groups).\nDefaults: evals, experiments.`,
109
+ validate: `Usage: quickstudy validate [--evals-root <dir>] [--experiments-root <dir>] [--oracle]\n\nDiscover and validate the evals root (frontmatter, scorer default exports)\nand the experiments root (defineExperiment shapes, comparison groups).\nDefaults: evals, experiments.\n\n--oracle scores each eval's solution/ reference solution on the host and\nprints 1.0, deferred (the scorer needs a runtime capability), FAILED or\nMISSING per eval; exits 1 on any FAILED or MISSING.`,
108
110
  run: `Usage: quickstudy run [--eval <id>...] [--experiment <id>...] [options]\n\nExecute the selected eval × experiment pairs. Omit --eval to select every\ndiscovered eval; omit --experiment to select every discovered experiment.\n\nOptions:\n --eval <id>... --experiment <id>...\n --evals-root <dir> --experiments-root <dir> (defaults: evals, experiments)\n --suite <s>... --framework <f>...\n --trials <n> --egress-proxy\n --concurrency <n> --seed <value> (default concurrency: 1)\n --resume <run-id> --retry-unfinished (explicit error/timeout retries)\n --provision-ms <n> --agent-ms <n> --score-ms <n>\n --export-ms <n> --cleanup-ms <n>\n --db <path> --results <dir>`,
109
111
  report: `Usage: quickstudy report <run-id> [options]\n\nBuild report.json into the results dir and print the text report\n(pass \`--latest\` to pick the most recent run; \`--json\` prints the JSON).\n\nOptions:\n --latest --json --strict\n --strict exits non-zero for incomplete runs or any attempt error\n --pricing <file> Estimate missing Codex token costs using runtime pricing JSON\n --db <path> --results <dir>`,
110
112
  diagnose: `Usage: quickstudy diagnose <run-id> [options]\n\nSend each failing (eval × experiment) pair's harness-selected evidence to a\nstructured model call and write diagnosis.json beside report.json\n(pass \`--latest\` to pick the most recent run). Findings are hypotheses with\nevidence links — never verdicts. Requires ANTHROPIC_API_KEY; override the\nanalyst model with QUICKSTUDY_ANALYST_MODEL.\n\nOptions:\n --latest\n --db <path> --results <dir>`,
@@ -126,7 +128,7 @@ function fail(message: string): never {
126
128
  }
127
129
 
128
130
  async function commandValidate(args: string[]): Promise<void> {
129
- let values: { "evals-root": string; "experiments-root": string };
131
+ let values: { "evals-root": string; "experiments-root": string; oracle: boolean };
130
132
  let positionals: string[];
131
133
  try {
132
134
  ({ values, positionals } = parseArgs({
@@ -135,6 +137,7 @@ async function commandValidate(args: string[]): Promise<void> {
135
137
  options: {
136
138
  "evals-root": { type: "string", default: "evals" },
137
139
  "experiments-root": { type: "string", default: "experiments" },
140
+ oracle: { type: "boolean", default: false },
138
141
  },
139
142
  }));
140
143
  } catch (err) {
@@ -157,6 +160,10 @@ async function commandValidate(args: string[]): Promise<void> {
157
160
  `("${experimentsRoot}") exists\n\n${USAGE}`,
158
161
  );
159
162
  }
163
+ if (values.oracle && !haveEvals) {
164
+ fail(`validate --oracle: evals root "${evalsRoot}" does not exist`);
165
+ }
166
+ let oracleFailure: string | undefined;
160
167
  if (haveEvals) {
161
168
  const evals = discoverEvals(evalsRoot);
162
169
  // A missing/non-function default export is a validation error HERE, not
@@ -170,9 +177,24 @@ async function commandValidate(args: string[]): Promise<void> {
170
177
  ` - ${meta.id} (suite: ${meta.suite}` +
171
178
  `${meta.product !== undefined ? `; product: ${meta.product}` : ""}` +
172
179
  `${meta.framework !== undefined ? `; framework: ${meta.framework}` : ""}` +
173
- `${loaded.localDir !== null ? "; local/" : ""})`,
180
+ `${loaded.localDir !== null ? "; local/" : ""}` +
181
+ `${loaded.solutionDir !== null ? "; solution/" : ""})`,
174
182
  );
175
183
  }
184
+ if (values.oracle) {
185
+ console.log(` oracle:`);
186
+ let failed = 0;
187
+ let missing = 0;
188
+ for (const loaded of evals) {
189
+ const outcome = await validateOracle(loaded);
190
+ if (outcome.kind === "failed") failed++;
191
+ if (outcome.kind === "missing") missing++;
192
+ console.log(` - ${formatOracleOutcome(loaded.metadata.id, outcome)}`);
193
+ }
194
+ if (failed + missing > 0) {
195
+ oracleFailure = `validate --oracle: ${failed} eval(s) FAILED, ${missing} eval(s) MISSING a solution/`;
196
+ }
197
+ }
176
198
  }
177
199
  if (haveExperiments) {
178
200
  const experiments = await discoverExperiments(experimentsRoot);
@@ -200,6 +222,7 @@ async function commandValidate(args: string[]): Promise<void> {
200
222
  }
201
223
  }
202
224
  }
225
+ if (oracleFailure !== undefined) fail(oracleFailure);
203
226
  }
204
227
 
205
228
  function parsePositiveInt(value: string, flag: string): number {
@@ -29,8 +29,9 @@ export const BUNDLE_DIFF_CAP = 3_000;
29
29
 
30
30
  /**
31
31
  * Heuristic error scan. Matches benign prose too ("failed" in discussion) —
32
- * accepted: the window stays deterministic and human-verifiable, and the
33
- * tail fallback bounds the damage.
32
+ * accepted: the window stays deterministic and human-verifiable, and
33
+ * anchoring on the last marker cluster keeps an early false positive from
34
+ * pinning the window to the transcript's head.
34
35
  */
35
36
  export const ERROR_MARKER = /"is_error"\s*:\s*true|error:|failed|unauthorized|timed out|not found|exited [1-9]/i;
36
37
 
@@ -42,8 +43,11 @@ export interface ArtifactExcerpt {
42
43
  }
43
44
 
44
45
  /**
45
- * Deterministically select the excerpt window: context around the span of
46
- * error markers (capped), or the tail when nothing matches.
46
+ * Deterministically select the excerpt window: context around the last
47
+ * cluster of error markers (walking back from the final marker while the
48
+ * window stays within {@link EXCERPT_MAX_LINES}), or the tail when nothing
49
+ * matches. Earlier markers that do not fit are dropped; the window always
50
+ * ends at the last marker plus context.
47
51
  */
48
52
  export function excerptTranscript(content: string): ArtifactExcerpt {
49
53
  const lines = content.split("\n");
@@ -63,11 +67,18 @@ export function excerptTranscript(content: string): ArtifactExcerpt {
63
67
  start = Math.max(0, totalLines - EXCERPT_TAIL_LINES);
64
68
  end = totalLines - 1;
65
69
  } else {
66
- start = Math.max(0, (matches[0] as number) - EXCERPT_CONTEXT_LINES);
67
- end = Math.min(totalLines - 1, (matches.at(-1) as number) + EXCERPT_CONTEXT_LINES);
68
- if (end - start + 1 > EXCERPT_MAX_LINES) {
69
- end = start + EXCERPT_MAX_LINES - 1;
70
+ // Anchor on the last marker cluster: walk back from the final marker while
71
+ // the span plus context still fits the window. Early markers (often
72
+ // system-prompt prose) no longer pin the window to the transcript's head.
73
+ const last = matches.at(-1) as number;
74
+ let first = last;
75
+ for (let i = matches.length - 2; i >= 0; i -= 1) {
76
+ const candidate = matches[i] as number;
77
+ if (last - candidate + 2 * EXCERPT_CONTEXT_LINES + 1 > EXCERPT_MAX_LINES) break;
78
+ first = candidate;
70
79
  }
80
+ start = Math.max(0, first - EXCERPT_CONTEXT_LINES);
81
+ end = Math.min(totalLines - 1, last + EXCERPT_CONTEXT_LINES);
71
82
  }
72
83
 
73
84
  return {
@@ -100,7 +100,7 @@ export function selectDiagnosablePairs(attempts: AttemptRecord[]): PairSelection
100
100
  experimentId: report.experiment_id,
101
101
  tallies: report,
102
102
  flaky: report.flaky,
103
- failedChecks: report.checks.filter((check) => check.passed < check.total).map((check) => check.name),
103
+ failedChecks: report.checks.filter((check) => check.graded !== false && check.passed < check.total).map((check) => check.name),
104
104
  bundle,
105
105
  });
106
106
  }
@@ -20,8 +20,11 @@ export class DiagnoseError extends Error {
20
20
 
21
21
  export const DIAGNOSIS_SCHEMA_VERSION = 1;
22
22
 
23
- /** Bumped whenever the system/user prompt text changes shape. */
24
- export const DIAGNOSIS_PROMPT_VERSION = 1;
23
+ /**
24
+ * Bumped whenever the system/user prompt text changes shape. v2: excerpt
25
+ * windows anchor on the last marker cluster.
26
+ */
27
+ export const DIAGNOSIS_PROMPT_VERSION = 2;
25
28
 
26
29
  /** Fixed self-labeling carried inside every diagnosis.json. */
27
30
  export const DIAGNOSIS_DISCLAIMER =
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * Eval discovery: scan each `<root>/<dir>/PROMPT.md`, pair it with its EVAL.ts, and
3
- * detect the optional `local/` starting-state directory. Discovery is dumb on
3
+ * detect the optional `local/` starting-state and `solution/` reference-solution
4
+ * directories. Discovery is dumb on
4
5
  * purpose — one visible expansion (a `frameworks:` frontmatter list yields
5
6
  * one eval per framework, id `<dirname>-<framework>`), no placeholders, no
6
7
  * binding — and loud: the first error aborts the scan.
@@ -53,8 +54,18 @@ export function discoverEvals(root: string): LoadedEval[] {
53
54
  if (frameworks === undefined) {
54
55
  const localDir = join(dir, "local");
55
56
  const hasLocal = existsSync(localDir) && statSync(localDir).isDirectory();
57
+ const solutionDir = join(dir, "solution");
58
+ const hasSolution = existsSync(solutionDir) && statSync(solutionDir).isDirectory();
56
59
  collected.push({
57
- loaded: { metadata, dir, promptPath, promptBody, scorerPath, localDir: hasLocal ? localDir : null },
60
+ loaded: {
61
+ metadata,
62
+ dir,
63
+ promptPath,
64
+ promptBody,
65
+ scorerPath,
66
+ localDir: hasLocal ? localDir : null,
67
+ solutionDir: hasSolution ? solutionDir : null,
68
+ },
58
69
  });
59
70
  continue;
60
71
  }
@@ -85,6 +96,11 @@ export function discoverEvals(root: string): LoadedEval[] {
85
96
  );
86
97
  }
87
98
  }
99
+ // Reference solutions always live under the eval's own solution/ (no
100
+ // shared-path form). When solution/ exists it must cover every listed
101
+ // framework; when absent every variant has no solution.
102
+ const solutionBase = join(dir, "solution");
103
+ const hasSolutionBase = existsSync(solutionBase) && statSync(solutionBase).isDirectory();
88
104
  // No starting state at all (no local/ and no `local:`) is valid — every
89
105
  // variant starts from an empty workspace, like a tools-only eval.
90
106
  for (const framework of frameworks) {
@@ -101,6 +117,19 @@ export function discoverEvals(root: string): LoadedEval[] {
101
117
  }
102
118
  localDir = frameworkDir;
103
119
  }
120
+ let solutionDir: string | null = null;
121
+ if (hasSolutionBase) {
122
+ const frameworkSolution = join(solutionBase, framework);
123
+ if (!existsSync(frameworkSolution) || !statSync(frameworkSolution).isDirectory()) {
124
+ throw new EvalLoadError(
125
+ promptPath,
126
+ 1,
127
+ `eval "${metadata.id}" has solution/ but no solution for framework "${framework}" ` +
128
+ `(expected ${frameworkSolution})`,
129
+ );
130
+ }
131
+ solutionDir = frameworkSolution;
132
+ }
104
133
  collected.push({
105
134
  loaded: {
106
135
  metadata: { ...metadata, id: `${metadata.id}-${framework}`, framework },
@@ -109,6 +138,7 @@ export function discoverEvals(root: string): LoadedEval[] {
109
138
  promptBody,
110
139
  scorerPath,
111
140
  localDir,
141
+ solutionDir,
112
142
  },
113
143
  framework,
114
144
  });
@@ -3,7 +3,7 @@ export function validEvalResult(value: unknown): value is EvalResult {
3
3
  if (!value || typeof value !== "object") return false;
4
4
  const result = value as EvalResult;
5
5
  if (typeof result.passed !== "boolean" || !Array.isArray(result.checks)) return false;
6
- if (result.checks.some((c) => !c || typeof c.name !== "string" || c.name === "" || typeof c.passed !== "boolean")) return false;
6
+ if (result.checks.some((c) => !c || typeof c.name !== "string" || c.name === "" || typeof c.passed !== "boolean" || (c.graded !== undefined && typeof c.graded !== "boolean"))) return false;
7
7
  if (new Set(result.checks.map((c) => c.name)).size !== result.checks.length) return false;
8
8
  return result.judgments === undefined || (Array.isArray(result.judgments) && result.judgments.every((j) => j && typeof j === "object" && j.provenance === "semantic-judge" &&
9
9
  typeof j.provider === "string" && typeof j.model === "string" && [j.tokensIn, j.tokensOut, j.costUsd].every((v) => v === null || (typeof v === "number" && Number.isFinite(v) && v >= 0))));
@@ -32,6 +32,13 @@ export interface LoadedEval {
32
32
  scorerPath: string;
33
33
  /** Absolute path of the optional `local/` starting-state directory, or null. */
34
34
  localDir: string | null;
35
+ /**
36
+ * Absolute path of the optional `solution/` reference solution (or
37
+ * `solution/<framework>/` for a framework-expanded eval), or null. Overlaid
38
+ * on the starting state only for adapters with `startingState: "solution"`
39
+ * (the built-in oracle) and by `validate --oracle`; agents never see it.
40
+ */
41
+ solutionDir: string | null;
35
42
  }
36
43
 
37
44
  /** A pointer at evidence backing a check verdict (workspace-relative path). */
@@ -44,6 +51,11 @@ export interface EvidenceReference {
44
51
  export interface CheckResult {
45
52
  name: string;
46
53
  passed: boolean;
54
+ /**
55
+ * false = recorded but not graded: the report tallies it and derives SDK
56
+ * recall from it, and the scorer must leave it out of `passed`. Absent = true.
57
+ */
58
+ graded?: boolean;
47
59
  notes?: string;
48
60
  evidence?: EvidenceReference[];
49
61
  }
@@ -89,6 +89,13 @@ export interface DataAttempt {
89
89
  finished_at: string | null;
90
90
  /** Present when status = "error" (and for recorded teardown notes). */
91
91
  error: string | null;
92
+ /** "timeout" when the agent hit its wall-clock limit and the tree it left was graded; null otherwise. */
93
+ limit: "timeout" | null;
94
+ /** Which stage failed when status = "error"; null for completed/incomplete rows and for rows written before this field existed. */
95
+ error_kind: "environment" | "agent" | "scorer" | "harness" | null;
96
+ /** Agent phase only (excludes provisioning, container start, export, scoring); null = not recorded. */
97
+ agent_started_at: string | null;
98
+ agent_finished_at: string | null;
92
99
  /** Relative to the run's data dir (e.g. `attempts/<id>.transcript.txt`); null = artifact missing. */
93
100
  transcript: string | null;
94
101
  diff: string | null;
package/src/export.ts CHANGED
@@ -33,7 +33,7 @@ import {
33
33
  } from "./export-types.ts";
34
34
  import { trialCoverage } from "./completeness.ts";
35
35
  import { hashString } from "./hash.ts";
36
- import { buildReport, renderReportText, type ReportJson } from "./report/report.ts";
36
+ import { buildReport, REPORT_SCHEMA_VERSION, renderReportText, type ReportJson } from "./report/report.ts";
37
37
  import { parsePricing, type Pricing } from "./report/costs.ts";
38
38
  import { activeAttempts, type AttemptRecord, type ResultsStore, type RunRecord } from "./store/db.ts";
39
39
 
@@ -162,6 +162,10 @@ function toDataAttempt(
162
162
  started_at: iso(attempt.startedAt),
163
163
  finished_at: iso(attempt.finishedAt),
164
164
  error: attempt.error,
165
+ limit: attempt.limitHit ?? null,
166
+ error_kind: attempt.errorKind ?? null,
167
+ agent_started_at: iso(attempt.agentStartedAt ?? null),
168
+ agent_finished_at: iso(attempt.agentFinishedAt ?? null),
165
169
  transcript,
166
170
  diff,
167
171
  };
@@ -184,11 +188,18 @@ function resolveReport(
184
188
  const path = join(resultsDir, runId, "report.json");
185
189
  if (existsSync(path)) {
186
190
  try {
187
- // Export is an artifact copier, not report validation. Preserve the
188
- // stored JSON byte-for-byte in meaning, including forward-compatible
189
- // fields a newer report producer may add.
191
+ // Export is an artifact copier, not report validation. A current-schema
192
+ // report is preserved byte-for-byte in meaning; any other schema is
193
+ // rebuilt from the store so the bundled UI reads the shape it knows.
190
194
  const stored = JSON.parse(readFileSync(path, "utf8")) as ReportJson;
191
195
  const run = store.getRun(runId)!;
196
+ // A report written under another schema is rebuilt so every schema bump
197
+ // reaches the exported site; a stored pricing snapshot is reused.
198
+ if (stored.schema_version !== REPORT_SCHEMA_VERSION) {
199
+ log(`stored report.json for ${runId} is schema ${stored.schema_version ?? "unversioned"} — rebuilding at ${REPORT_SCHEMA_VERSION}`);
200
+ return buildReport({ store, runId, resultsDir, generatedAt: generatedAt ?? iso(run.finishedAt) ?? iso(run.startedAt)!,
201
+ ...(stored.cost_estimate ? { pricing: parsePricing(stored.cost_estimate.pricing) } : {}) });
202
+ }
192
203
  // Historical reports retain their methodology. New reports are reused
193
204
  // only when the entire source record still matches (including retries).
194
205
  if (run.manifest.manifestVersion === "v3" && stored.cost_estimate === undefined) return stored;
package/src/manifest.ts CHANGED
@@ -57,6 +57,11 @@ export interface EvalIdentity {
57
57
  metadata: EvalMetadata;
58
58
  components: EvalIdentityComponents;
59
59
  hash: string;
60
+ /**
61
+ * Tree hash of `solution/` (the reference solution) when present; null
62
+ * otherwise. Recorded, never hashed into `hash` — agents never see it.
63
+ */
64
+ solution: string | null;
60
65
  }
61
66
 
62
67
  /** Per-experiment identity components — phase 2's comparison-group diff reads these. */
@@ -167,6 +172,7 @@ export function buildRunManifest(options: BuildRunManifestOptions): RunManifest
167
172
  metadata: loaded.metadata,
168
173
  components,
169
174
  hash: contentHash(components),
175
+ solution: loaded.solutionDir === null ? null : hashTree(loaded.solutionDir).sha256,
170
176
  };
171
177
  }
172
178