@workos/quickstudy 0.0.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 (75) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +270 -0
  3. package/examples/harbor-notes/README.md +40 -0
  4. package/examples/harbor-notes/evals/create-note/EVAL.ts +14 -0
  5. package/examples/harbor-notes/evals/create-note/PROMPT.md +9 -0
  6. package/examples/harbor-notes/evals/create-note/local/README.txt +1 -0
  7. package/examples/harbor-notes/experiments/scripted.ts +6 -0
  8. package/examples/harbor-notes/package.json +6 -0
  9. package/examples/harbor-notes/quickstudy.identity.json +1 -0
  10. package/examples/harbor-notes/runtime.ts +48 -0
  11. package/examples/harbor-notes/semantic-example.ts +21 -0
  12. package/images/agent-runtime/Dockerfile +58 -0
  13. package/images/egress-proxy/Dockerfile +28 -0
  14. package/images/mcp-proxy/Dockerfile +30 -0
  15. package/package.json +53 -0
  16. package/src/adapters/claude.ts +107 -0
  17. package/src/adapters/codex.ts +107 -0
  18. package/src/adapters/echo.ts +57 -0
  19. package/src/adapters/parse.ts +117 -0
  20. package/src/adapters/types.ts +152 -0
  21. package/src/build-info.generated.ts +12 -0
  22. package/src/cli.ts +787 -0
  23. package/src/completeness.ts +104 -0
  24. package/src/diagnose/excerpt.ts +106 -0
  25. package/src/diagnose/prompt.ts +175 -0
  26. package/src/diagnose/render.ts +55 -0
  27. package/src/diagnose/run.ts +290 -0
  28. package/src/diagnose/select.ts +110 -0
  29. package/src/diagnose/types.ts +88 -0
  30. package/src/evals/discovery.ts +173 -0
  31. package/src/evals/prompt.ts +190 -0
  32. package/src/evals/result.ts +10 -0
  33. package/src/evals/types.ts +115 -0
  34. package/src/execution-policy.ts +71 -0
  35. package/src/experiments/discovery.ts +76 -0
  36. package/src/experiments/groups.ts +119 -0
  37. package/src/experiments/types.ts +116 -0
  38. package/src/export-types.ts +127 -0
  39. package/src/export.ts +381 -0
  40. package/src/hash.ts +74 -0
  41. package/src/identity-diff.ts +30 -0
  42. package/src/ids.ts +30 -0
  43. package/src/index.ts +58 -0
  44. package/src/isolation/docker.ts +639 -0
  45. package/src/isolation/image-contexts.generated.ts +927 -0
  46. package/src/isolation/images.ts +138 -0
  47. package/src/isolation/mcp-proxy/server.ts +260 -0
  48. package/src/isolation/mcp.ts +144 -0
  49. package/src/isolation/proxy/allowlist.ts +148 -0
  50. package/src/isolation/proxy/server.ts +382 -0
  51. package/src/llm.ts +132 -0
  52. package/src/manifest.ts +228 -0
  53. package/src/model-identity.ts +12 -0
  54. package/src/plan.ts +55 -0
  55. package/src/probe.ts +426 -0
  56. package/src/report/pass-at-k.ts +76 -0
  57. package/src/report/report.ts +731 -0
  58. package/src/runner/context.ts +96 -0
  59. package/src/runner/deadline.ts +37 -0
  60. package/src/runner/execute.ts +992 -0
  61. package/src/runner/run-lock.ts +32 -0
  62. package/src/runner/scheduler.ts +62 -0
  63. package/src/runner/score-worker.ts +107 -0
  64. package/src/runner/scorer-worker.ts +61 -0
  65. package/src/runtime/types.ts +89 -0
  66. package/src/secrets.ts +151 -0
  67. package/src/semantic.ts +185 -0
  68. package/src/serve.ts +52 -0
  69. package/src/source-identity.ts +76 -0
  70. package/src/store/artifacts.ts +146 -0
  71. package/src/store/db.ts +318 -0
  72. package/src/store/schema.ts +39 -0
  73. package/src/surface-usage.ts +297 -0
  74. package/src/ui-bundle.generated.ts +12 -0
  75. package/ui/dist/index.html +32 -0
package/src/cli.ts ADDED
@@ -0,0 +1,787 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * quickstudy command router.
4
+ *
5
+ * Every load error exits non-zero with file/line context — an eval or
6
+ * experiment is never partially loaded, and nothing (containers, agents)
7
+ * starts before the roots validate and every preflight (provider keys,
8
+ * MCP credentials) passes.
9
+ */
10
+
11
+ import * as quickstudyApi from "./index.ts";
12
+
13
+ // Consumer modules (EVAL.ts, experiments/*.ts) import the harness by package
14
+ // name. From source, Bun resolves that self-reference through the root
15
+ // package.json; the compiled binary has no package.json or node_modules to
16
+ // consult when it dynamically imports those files, so the bare specifier must
17
+ // be served by the binary itself. Serving the embedded API also guarantees
18
+ // exactly one copy of the harness at runtime — a disk-resolved second copy
19
+ // would break instanceof checks and identity hashing.
20
+ Bun.plugin({
21
+ name: "quickstudy-self-resolution",
22
+ setup(build) {
23
+ build.module("quickstudy", () => ({ exports: quickstudyApi, loader: "object" }));
24
+ },
25
+ });
26
+
27
+ import { existsSync, mkdtempSync, statSync, watch, writeFileSync } from "node:fs";
28
+ import { tmpdir } from "node:os";
29
+ import { basename, dirname, join, resolve } from "node:path";
30
+ import { parseArgs } from "node:util";
31
+ import {
32
+ dockerDaemonReachable,
33
+ removeAttemptContainers,
34
+ removeRunResources,
35
+ } from "./isolation/docker.ts";
36
+ import {
37
+ buildAgentRuntimeImage,
38
+ buildEgressProxyImage,
39
+ buildMcpProxyImage,
40
+ } from "./isolation/images.ts";
41
+ import { EMBEDDED_BUILD_INFO } from "./build-info.generated.ts";
42
+ import {
43
+ buildReport,
44
+ renderReportText,
45
+ ReportError,
46
+ writeReportJson,
47
+ type ReportJson,
48
+ } from "./report/report.ts";
49
+ import { ExportError, exportSite } from "./export.ts";
50
+ import { renderDiagnosisText } from "./diagnose/render.ts";
51
+ import { diagnosisJsonPath, runDiagnose } from "./diagnose/run.ts";
52
+ import { DiagnoseError } from "./diagnose/types.ts";
53
+ import { AnthropicModelClient, ModelOutputError } from "./llm.ts";
54
+ import { discoverEvals, importEvalScorer } from "./evals/discovery.ts";
55
+ import { EvalLoadError } from "./evals/prompt.ts";
56
+ import { discoverExperiments, ExperimentLoadError } from "./experiments/discovery.ts";
57
+ import { compareGroups } from "./experiments/groups.ts";
58
+ import { buildRunManifest } from "./manifest.ts";
59
+ import { filterEvals, planRun } from "./plan.ts";
60
+ import { executeRunV3 } from "./runner/execute.ts";
61
+ import { collectProviderKeys, InvalidProviderKeyError, MissingProviderKeyError } from "./secrets.ts";
62
+ import { serveSite } from "./serve.ts";
63
+ import { UI_INDEX_HTML_BASE64 } from "./ui-bundle.generated.ts";
64
+ import { ArtifactsStore } from "./store/artifacts.ts";
65
+ import { ResultsStore } from "./store/db.ts";
66
+
67
+ const USAGE = `quickstudy — eval × experiment harness for coding agents
68
+
69
+ Usage:
70
+ quickstudy validate Validate the evals and experiments roots
71
+ [--evals-root <dir>] Evals root (default: evals)
72
+ [--experiments-root <dir>] Experiments root (default: experiments)
73
+ quickstudy run [--eval <id>...] Execute evals against experiments
74
+ [--experiment <id>...] Omit either selector to include every discovered item
75
+ [--evals-root <dir>] Evals root (default: evals)
76
+ [--experiments-root <dir>] Experiments root (default: experiments)
77
+ [--suite <s>]... [--framework <f>]... Metadata filters applied before planning
78
+ [--trials <n>] Trials per eval × experiment pair (default: 3)
79
+ [--egress-proxy] Route container egress through the per-attempt allowlist proxy
80
+ [--db <path>] [--results <dir>] Stores (default: results.db, results/)
81
+ quickstudy clean Remove all quickstudy-labeled containers and networks
82
+ quickstudy images build [--pull] Build the agent-runtime, egress-proxy and mcp-proxy images
83
+ [--images-dir <dir>] Build from this checkout's Dockerfiles instead of the embedded copies
84
+ quickstudy report <run-id> Build report.json for a run and print the text report
85
+ [--latest] Report the most recent run instead of naming one
86
+ [--json] Print report.json instead of the text report
87
+ [--strict] Exit non-zero when incomplete or any attempt errored (CI/publication)
88
+ [--db <path>] [--results <dir>] Stores (default: results.db, results/)
89
+ quickstudy diagnose <run-id> LLM-diagnose a run's failing pairs into diagnosis.json
90
+ [--latest] Diagnose the most recent run instead of naming one
91
+ [--db <path>] [--results <dir>] Stores (default: results.db, results/)
92
+ quickstudy export <run-id>... Materialize a run as a self-contained static site
93
+ [--runs <a,b|all>] More run ids (comma-separated), or "all" for every run in the DB
94
+ [--out <dir>] Output directory (default: ./site)
95
+ [--db <path>] [--results <dir>] Stores (default: results.db, results/)
96
+ quickstudy ui Export to a temp dir and serve the UI locally
97
+ [--runs <a,b>] Runs to include (default: every run in the DB)
98
+ [--port <n>] Listen port (default: 4173)
99
+ [--watch] Re-export when the results DB changes
100
+ [--db <path>] [--results <dir>] Stores (default: results.db, results/)
101
+ `;
102
+
103
+ const COMMAND_HELP: Record<string, string> = {
104
+ 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.`,
105
+ 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>`,
106
+ 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 --db <path> --results <dir>`,
107
+ 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>`,
108
+ clean: `Usage: quickstudy clean\n\nRemove quickstudy-labeled containers, networks, and sidecars.`,
109
+ export: `Usage: quickstudy export <run-id>... [options]\n\nBuild a self-contained static site.\n\nOptions:\n --runs <a,b|all> --out <dir> --db <path> --results <dir>\n --mode <offline|hosted> (default: offline)\n --expected-trials <n> Publish fully sampled pairs; fail if none\n Writes omissions and retains source diagnostics`,
110
+ ui: `Usage: quickstudy ui [options]\n\nExport stored runs to a temporary directory and serve the local UI.\n\nOptions:\n --runs <a,b> --port <n> --watch --db <path> --results <dir>`,
111
+ };
112
+
113
+ const IMAGES_HELP = `Usage: quickstudy images <command>\n\nCommands:\n build Build the agent runtime, egress proxy, and MCP proxy images.\n\nRun \`quickstudy images build --help\` for build options.`;
114
+ const IMAGES_BUILD_HELP = `Usage: quickstudy images build [--pull] [--images-dir <dir>]\n\nBuild all quickstudy runtime and proxy images. --pull refreshes base images; --images-dir uses contexts from a checkout.`;
115
+
116
+ function helpRequested(args: string[]): boolean {
117
+ return args.includes("--help") || args.includes("-h");
118
+ }
119
+
120
+ function fail(message: string): never {
121
+ console.error(`quickstudy: ${message}`);
122
+ process.exit(1);
123
+ }
124
+
125
+ async function commandValidate(args: string[]): Promise<void> {
126
+ let values: { "evals-root": string; "experiments-root": string };
127
+ let positionals: string[];
128
+ try {
129
+ ({ values, positionals } = parseArgs({
130
+ args,
131
+ allowPositionals: true,
132
+ options: {
133
+ "evals-root": { type: "string", default: "evals" },
134
+ "experiments-root": { type: "string", default: "experiments" },
135
+ },
136
+ }));
137
+ } catch (err) {
138
+ fail(`validate: ${err instanceof Error ? err.message : String(err)}\n\n${USAGE}`);
139
+ }
140
+ if (positionals.length > 0) {
141
+ fail(
142
+ `validate: unexpected argument "${positionals[0]}" — validate takes no positionals; ` +
143
+ `point --evals-root/--experiments-root at your directories instead`,
144
+ );
145
+ }
146
+
147
+ const evalsRoot = values["evals-root"];
148
+ const experimentsRoot = values["experiments-root"];
149
+ const haveEvals = existsSync(evalsRoot) && statSync(evalsRoot).isDirectory();
150
+ const haveExperiments = existsSync(experimentsRoot) && statSync(experimentsRoot).isDirectory();
151
+ if (!haveEvals && !haveExperiments) {
152
+ fail(
153
+ `validate: neither an evals root ("${evalsRoot}") nor an experiments root ` +
154
+ `("${experimentsRoot}") exists\n\n${USAGE}`,
155
+ );
156
+ }
157
+ if (haveEvals) {
158
+ const evals = discoverEvals(evalsRoot);
159
+ // A missing/non-function default export is a validation error HERE, not
160
+ // at attempt time.
161
+ for (const loaded of evals) await importEvalScorer(loaded.scorerPath);
162
+ console.log(`evals root "${evalsRoot}" is valid`);
163
+ console.log(` evals: ${evals.length}`);
164
+ for (const loaded of evals) {
165
+ const meta = loaded.metadata;
166
+ console.log(
167
+ ` - ${meta.id} (suite: ${meta.suite}` +
168
+ `${meta.product !== undefined ? `; product: ${meta.product}` : ""}` +
169
+ `${meta.framework !== undefined ? `; framework: ${meta.framework}` : ""}` +
170
+ `${loaded.localDir !== null ? "; local/" : ""})`,
171
+ );
172
+ }
173
+ }
174
+ if (haveExperiments) {
175
+ const experiments = await discoverExperiments(experimentsRoot);
176
+ console.log(`experiments root "${experimentsRoot}" is valid`);
177
+ console.log(` experiments: ${experiments.length}`);
178
+ for (const { experiment } of experiments) {
179
+ console.log(
180
+ ` - ${experiment.id} (agent: ${experiment.agent.adapter}; runtime: ${experiment.runtime.kind}` +
181
+ `${experiment.treatment !== undefined ? `; treatment: ${experiment.treatment}` : ""}` +
182
+ `${experiment.comparisonGroup !== undefined ? `; group: ${experiment.comparisonGroup}` : ""})`,
183
+ );
184
+ }
185
+ // Comparison groups: agreement is checked here so an author sees a
186
+ // withheld group at validate time, not after a paid run. Never an error —
187
+ // withhold-and-diff, by design.
188
+ const groups = compareGroups(
189
+ experiments.map((entry) => entry.experiment),
190
+ buildRunManifest({ evals: [], experiments }),
191
+ );
192
+ if (groups.length > 0) {
193
+ console.log(` comparison groups: ${groups.length}`);
194
+ for (const group of groups) {
195
+ console.log(` - ${group.group}: ${group.experimentIds.join(", ")} (comparison ${group.comparison})`);
196
+ for (const detail of group.details) console.log(` ${detail}`);
197
+ }
198
+ }
199
+ }
200
+ }
201
+
202
+ function parsePositiveInt(value: string, flag: string): number {
203
+ const parsed = Number(value);
204
+ if (!Number.isInteger(parsed) || parsed < 1) fail(`${flag} must be an integer >= 1, got "${value}"`);
205
+ return parsed;
206
+ }
207
+
208
+ /** The run path: discover, filter, plan, execute, persist, summarize. */
209
+ async function commandRun(args: string[]): Promise<void> {
210
+ let values: RunFlags;
211
+ try {
212
+ values = parseRunFlags(args);
213
+ } catch (err) {
214
+ fail(`run: ${err instanceof Error ? err.message : String(err)}\n\n${USAGE}`);
215
+ }
216
+ if (values.resume) {
217
+ const resumeStore = new ResultsStore(values.db);
218
+ try {
219
+ const previous = resumeStore.getRun(values.resume);
220
+ if (!previous) fail(`run: no run "${values.resume}" to resume`);
221
+ const supplied = (flag: string) => args.some((arg) => arg === flag || arg.startsWith(`${flag}=`));
222
+ if (!supplied("--trials")) values.trials = String(previous.config.trials);
223
+ if (!supplied("--eval")) values.eval = previous.config.evalIds;
224
+ if (!supplied("--experiment")) values.experiment = previous.config.experimentIds;
225
+ if (!supplied("--evals-root")) values["evals-root"] = previous.config.evalsRoot ?? values["evals-root"];
226
+ if (!supplied("--experiments-root")) values["experiments-root"] = previous.config.experimentsRoot ?? values["experiments-root"];
227
+ if (!supplied("--egress-proxy")) values["egress-proxy"] = previous.config.egress?.proxy ?? false;
228
+ const budgets = Object.values(previous.manifest.pairs ?? {})[0]?.budgets;
229
+ for (const [flag, key] of [["provision-ms", "provisionMs"], ["agent-ms", "agentMs"], ["score-ms", "scoreMs"], ["export-ms", "exportMs"], ["cleanup-ms", "cleanupMs"]] as const) {
230
+ if (!supplied(`--${flag}`) && budgets) values[flag] = String(budgets[key]);
231
+ }
232
+ } finally { resumeStore.close(); }
233
+ }
234
+ const trials = parsePositiveInt(values.trials, "--trials");
235
+ const evalsRoot = values["evals-root"];
236
+ const experimentsRoot = values["experiments-root"];
237
+
238
+ let evals;
239
+ try {
240
+ evals = discoverEvals(evalsRoot);
241
+ } catch (err) {
242
+ if (err instanceof Error && /is not a directory/.test(err.message)) {
243
+ fail(`run: ${err.message} (point --evals-root at your evals directory)`);
244
+ }
245
+ throw err;
246
+ }
247
+ let experiments;
248
+ try {
249
+ experiments = await discoverExperiments(experimentsRoot);
250
+ } catch (err) {
251
+ if (err instanceof Error && /is not a directory/.test(err.message)) {
252
+ fail(`run: ${err.message} (point --experiments-root at your experiments directory)`);
253
+ }
254
+ throw err;
255
+ }
256
+
257
+ const evalSelection = values.eval ?? [];
258
+ for (const id of evalSelection) {
259
+ if (!evals.some((entry) => entry.metadata.id === id)) {
260
+ fail(`run: no eval "${id}" under ${evalsRoot} (available: ${evals.map((entry) => entry.metadata.id).join(", ") || "none"})`);
261
+ }
262
+ }
263
+ if (evalSelection.length > 0) evals = evals.filter((entry) => evalSelection.includes(entry.metadata.id));
264
+
265
+ const experimentSelection = values.experiment ?? [];
266
+ for (const id of experimentSelection) {
267
+ if (!experiments.some((entry) => entry.experiment.id === id)) {
268
+ fail(
269
+ `run: no experiment "${id}" under ${experimentsRoot} ` +
270
+ `(available: ${experiments.map((entry) => entry.experiment.id).join(", ") || "none"})`,
271
+ );
272
+ }
273
+ }
274
+ if (experimentSelection.length > 0) {
275
+ experiments = experiments.filter((entry) => experimentSelection.includes(entry.experiment.id));
276
+ }
277
+
278
+ // Metadata filters apply to the eval list BEFORE planning.
279
+ evals = filterEvals(evals, {
280
+ ...(values.suite ? { suites: values.suite } : {}),
281
+ ...(values.framework ? { frameworks: values.framework } : {}),
282
+ });
283
+
284
+ const plan = planRun(evals, experiments.map((entry) => entry.experiment), trials);
285
+ if (plan.length === 0) {
286
+ fail(`run: nothing to run — the selection matched ${evals.length} eval(s) and ${experiments.length} experiment(s)`);
287
+ }
288
+
289
+ const store = new ResultsStore(values.db);
290
+ const artifacts = new ArtifactsStore(values.results);
291
+ const cancellation = new AbortController();
292
+ const interrupt = () => cancellation.abort(new Error("run interrupted"));
293
+ process.on("SIGINT", interrupt); process.on("SIGTERM", interrupt);
294
+ try {
295
+ const summary = await executeRunV3({
296
+ evals,
297
+ experiments,
298
+ plan,
299
+ store,
300
+ artifacts,
301
+ egressProxy: values["egress-proxy"],
302
+ ...(values.resume ? { resumeRunId: values.resume } : {}),
303
+ retryUnfinished: values["retry-unfinished"],
304
+ ...(values.concurrency ? { concurrency: parsePositiveInt(values.concurrency, "--concurrency") } : {}),
305
+ ...(values.seed !== undefined ? { seed: values.seed } : {}),
306
+ signal: cancellation.signal,
307
+ budgets: Object.fromEntries([["provision-ms", "provisionMs"], ["agent-ms", "agentMs"], ["score-ms", "scoreMs"], ["export-ms", "exportMs"], ["cleanup-ms", "cleanupMs"]].flatMap(([flag, key]) => {
308
+ const value = values[flag as keyof RunFlags]; return typeof value === "string" ? [[key, parsePositiveInt(value, `--${flag}`)]] : [];
309
+ })),
310
+ config: { trials, evalsRoot, experimentsRoot },
311
+ });
312
+ console.log(
313
+ `run ${summary.runId}: ${summary.total} attempts — ${summary.passed} passed, ${summary.failed} failed, ` +
314
+ `${summary.incomplete} incomplete, ${summary.errors} errored`,
315
+ );
316
+ console.log(` results: ${values.db} + ${values.results}/${summary.runId}/`);
317
+ if (summary.errors > 0 || summary.incomplete > 0) process.exitCode = 1;
318
+ } finally {
319
+ process.removeListener("SIGINT", interrupt); process.removeListener("SIGTERM", interrupt);
320
+ store.close();
321
+ }
322
+ }
323
+
324
+ async function commandReport(args: string[]): Promise<void> {
325
+ let values: ReportFlags;
326
+ let positionals: string[];
327
+ try {
328
+ ({ values, positionals } = parseArgs({
329
+ args,
330
+ allowPositionals: true,
331
+ options: {
332
+ json: { type: "boolean", default: false },
333
+ strict: { type: "boolean", default: false },
334
+ latest: { type: "boolean", default: false },
335
+ db: { type: "string", default: "results.db" },
336
+ results: { type: "string", default: "results" },
337
+ },
338
+ }));
339
+ } catch (err) {
340
+ fail(`report: ${err instanceof Error ? err.message : String(err)}\n\n${USAGE}`);
341
+ }
342
+
343
+ const requestedRunId = positionals[0];
344
+ if (!requestedRunId && !values.latest) fail(`report: missing <run-id> (or pass --latest)\n\n${USAGE}`);
345
+
346
+ const store = new ResultsStore(values.db);
347
+ try {
348
+ let runId = requestedRunId;
349
+ if (values.latest) {
350
+ const latest = store.latestRun();
351
+ if (latest === null) {
352
+ // Zero-state, not an error: an empty database is a valid starting point.
353
+ console.log("this results database has no runs yet — run `quickstudy run --eval <id> --experiment <id>` first");
354
+ return;
355
+ }
356
+ runId = latest.id;
357
+ }
358
+
359
+ const report: ReportJson = buildReport({ store, runId: runId as string, resultsDir: values.results });
360
+ const path = writeReportJson(report, values.results);
361
+ console.log(values.json ? JSON.stringify(report, null, 2) : renderReportText(report));
362
+ console.error(`report written: ${path}`);
363
+ if (values.strict) {
364
+ const problems = [
365
+ ...(report.incomplete ? ["incomplete"] : []),
366
+ ...(report.totals.errors > 0
367
+ ? [`contains ${report.totals.errors} attempt error${report.totals.errors === 1 ? "" : "s"}`]
368
+ : []),
369
+ ];
370
+ if (problems.length > 0) fail(`run ${report.run.id} is not publishable: ${problems.join("; ")} (--strict)`);
371
+ }
372
+ } finally {
373
+ store.close();
374
+ }
375
+ }
376
+
377
+ interface DiagnoseFlags {
378
+ latest: boolean;
379
+ db: string;
380
+ results: string;
381
+ }
382
+
383
+ /**
384
+ * The explicitly separate, key-gated, token-spending step: report stays
385
+ * offline, diagnose calls the model. A thin flag-parser over `runDiagnose` —
386
+ * the engine (including `--latest` resolution) is the testable surface.
387
+ */
388
+ async function commandDiagnose(args: string[]): Promise<void> {
389
+ let values: DiagnoseFlags;
390
+ let positionals: string[];
391
+ try {
392
+ ({ values, positionals } = parseArgs({
393
+ args,
394
+ allowPositionals: true,
395
+ options: {
396
+ latest: { type: "boolean", default: false },
397
+ db: { type: "string", default: "results.db" },
398
+ results: { type: "string", default: "results" },
399
+ },
400
+ }));
401
+ } catch (err) {
402
+ fail(`diagnose: ${err instanceof Error ? err.message : String(err)}\n\n${USAGE}`);
403
+ }
404
+
405
+ const requestedRunId = positionals[0];
406
+ if (!requestedRunId && !values.latest) fail(`diagnose: missing <run-id> (or pass --latest)\n\n${USAGE}`);
407
+
408
+ // Key preflight FIRST — before any store read, and long before any spend.
409
+ collectProviderKeys([{ name: "diagnose", requiredHostEnv: ["ANTHROPIC_API_KEY"] }]);
410
+
411
+ const store = new ResultsStore(values.db);
412
+ try {
413
+ if (values.latest && store.latestRun() === null) {
414
+ // Zero-state, not an error: an empty database is a valid starting point.
415
+ console.log("this results database has no runs yet — run `quickstudy run --eval <id> --experiment <id>` first");
416
+ return;
417
+ }
418
+ const diagnosis = await runDiagnose({
419
+ store,
420
+ resultsDir: values.results,
421
+ ...(values.latest ? {} : { runId: requestedRunId as string }),
422
+ client: new AnthropicModelClient(),
423
+ log: (line) => console.error(line),
424
+ });
425
+ console.log(renderDiagnosisText(diagnosis, values.results));
426
+ console.error(`diagnosis written: ${diagnosisJsonPath(values.results, diagnosis.run_id)}`);
427
+ if (diagnosis.pairs_diagnosed === 0 && diagnosis.errors.length > 0) {
428
+ fail(
429
+ `every diagnosable pair failed to diagnose (${diagnosis.errors.length} error(s)) — ` +
430
+ `the errors are recorded in diagnosis.json`,
431
+ );
432
+ }
433
+ } finally {
434
+ store.close();
435
+ }
436
+ }
437
+
438
+ async function commandClean(): Promise<void> {
439
+ if (!(await dockerDaemonReachable())) {
440
+ fail(
441
+ "clean: no Docker daemon reachable — nothing to inspect. " +
442
+ "Start your Docker runtime (Docker Desktop, OrbStack, Colima, …), confirm `docker ps` works, then re-run.",
443
+ );
444
+ }
445
+ // Attempt containers first: a run network cannot be removed while an
446
+ // attempt container is still attached.
447
+ const removed = await removeAttemptContainers();
448
+ const runResources = await removeRunResources();
449
+
450
+ const notes = [
451
+ removed > 0 ? `${removed} attempt container(s)` : "",
452
+ runResources.containers > 0 ? `${runResources.containers} sidecar container(s)` : "",
453
+ runResources.networks > 0 ? `${runResources.networks} run network(s)` : "",
454
+ ].filter((note) => note !== "");
455
+ console.log(notes.length === 0 ? "no quickstudy-labeled resources found — already clean" : `removed ${notes.join(", ")}`);
456
+ }
457
+
458
+ async function commandImages(args: string[]): Promise<void> {
459
+ const [subcommand, ...rest] = args;
460
+ if (subcommand === "--help" || subcommand === "-h") {
461
+ console.log(IMAGES_HELP);
462
+ return;
463
+ }
464
+ if (subcommand !== "build") {
465
+ fail(`images: unknown subcommand "${subcommand ?? ""}" (expected: build)\n\n${USAGE}`);
466
+ }
467
+ if (helpRequested(rest)) {
468
+ console.log(IMAGES_BUILD_HELP);
469
+ return;
470
+ }
471
+ let values: { pull: boolean; "images-dir"?: string };
472
+ try {
473
+ ({ values } = parseArgs({
474
+ args: rest,
475
+ options: { pull: { type: "boolean", default: false }, "images-dir": { type: "string" } },
476
+ }));
477
+ } catch (err) {
478
+ fail(`images build: ${err instanceof Error ? err.message : String(err)}\n\n${IMAGES_BUILD_HELP}`);
479
+ }
480
+ if (!(await dockerDaemonReachable())) {
481
+ fail(
482
+ "images build: no Docker daemon reachable. " +
483
+ "Start your Docker runtime (Docker Desktop, OrbStack, Colima, …), confirm `docker ps` works, then re-run.",
484
+ );
485
+ }
486
+ const imagesRoot = values["images-dir"];
487
+ try {
488
+ const exitCode = await buildAgentRuntimeImage({ pull: values.pull, imagesRoot });
489
+ if (exitCode !== 0) fail(`images build: docker build exited ${exitCode}`);
490
+ const proxyExitCode = await buildEgressProxyImage({ pull: values.pull, imagesRoot });
491
+ if (proxyExitCode !== 0) fail(`images build: egress-proxy docker build exited ${proxyExitCode}`);
492
+ const mcpExitCode = await buildMcpProxyImage({ pull: values.pull, imagesRoot });
493
+ if (mcpExitCode !== 0) fail(`images build: mcp-proxy docker build exited ${mcpExitCode}`);
494
+ } catch (err) {
495
+ // Missing build context (e.g. running the binary outside a checkout).
496
+ fail(`images build: ${err instanceof Error ? err.message : String(err)}`);
497
+ }
498
+ }
499
+
500
+ /** The prebuilt UI bundle shipped next to the CLI sources (source checkout). */
501
+ function uiBundleDir(): string {
502
+ return join(import.meta.dir, "../ui/dist");
503
+ }
504
+
505
+ /**
506
+ * Resolve a directory containing the UI shell (`index.html`).
507
+ *
508
+ * Source checkout: the on-disk `ui/dist` produced by `bun run ui:build`.
509
+ * Compiled binary: `ui/dist` isn't on disk, so materialize the embedded shell
510
+ * (see `ui-bundle.generated.ts`) into a temp dir and return that.
511
+ */
512
+ function mustUiBundleDir(): string {
513
+ const bundleDir = uiBundleDir();
514
+ if (existsSync(join(bundleDir, "index.html"))) return bundleDir;
515
+
516
+ if (UI_INDEX_HTML_BASE64.length > 0) {
517
+ const dir = mkdtempSync(join(tmpdir(), "quickstudy-ui-"));
518
+ writeFileSync(join(dir, "index.html"), Buffer.from(UI_INDEX_HTML_BASE64, "base64"));
519
+ return dir;
520
+ }
521
+
522
+ fail(
523
+ `the UI bundle is not built (expected ${bundleDir}).\n` +
524
+ "Build it once with `bun run ui:build`, then re-run.",
525
+ );
526
+ }
527
+
528
+ /** Positional run ids + comma-separated --runs, de-duplicated in order. */
529
+ function collectRunIds(positionals: string[], runsFlag: string | undefined): string[] {
530
+ const ids = [...positionals, ...(runsFlag ?? "").split(",")].map((id) => id.trim()).filter((id) => id !== "");
531
+ return [...new Set(ids)];
532
+ }
533
+
534
+ async function commandExport(args: string[]): Promise<void> {
535
+ let values: { runs?: string; out: string; db: string; results: string; mode: string; "expected-trials"?: string };
536
+ let positionals: string[];
537
+ try {
538
+ ({ values, positionals } = parseArgs({
539
+ args,
540
+ allowPositionals: true,
541
+ options: {
542
+ runs: { type: "string" },
543
+ out: { type: "string", default: "site" },
544
+ mode: { type: "string", default: "offline" },
545
+ "expected-trials": { type: "string" },
546
+ db: { type: "string", default: "results.db" },
547
+ results: { type: "string", default: "results" },
548
+ },
549
+ }));
550
+ } catch (err) {
551
+ fail(`export: ${err instanceof Error ? err.message : String(err)}\n\n${USAGE}`);
552
+ }
553
+
554
+ const bundleDir = mustUiBundleDir();
555
+ const store = new ResultsStore(values.db);
556
+ try {
557
+ // `--runs all`: every run in the DB. Explicit positionals are redundant
558
+ // but harmless (collectRunIds de-duplicates).
559
+ const runIds =
560
+ values.runs === "all"
561
+ ? collectRunIds([...positionals, ...store.listRuns().map((run) => run.id)], undefined)
562
+ : collectRunIds(positionals, values.runs);
563
+ if (runIds.length === 0) {
564
+ const known = store
565
+ .listRuns()
566
+ .slice(0, 5)
567
+ .map((run) => ` ${run.id} (started ${new Date(run.startedAt).toISOString()})`);
568
+ fail(`export: missing <run-id>${known.length > 0 ? `; most recent runs:\n${known.join("\n")}` : " (no runs in this database yet)"}`);
569
+ }
570
+ const summary = await exportSite({
571
+ store,
572
+ resultsDir: values.results,
573
+ runIds,
574
+ outDir: values.out,
575
+ mode: values.mode === "hosted" || values.mode === "offline" ? values.mode : fail("export --mode must be hosted or offline"),
576
+ ...(values["expected-trials"] ? { expectedTrials: parsePositiveInt(values["expected-trials"], "--expected-trials") } : {}),
577
+ bundleDir,
578
+ });
579
+ console.log(
580
+ `exported ${summary.runs} run(s), ${summary.attempts} attempt(s) -> ${summary.outDir}` +
581
+ (summary.missingAssets > 0 ? ` (${summary.missingAssets} missing artifact(s) skipped)` : ""),
582
+ );
583
+ console.log(values.mode === "hosted"
584
+ ? `serve ${summary.outDir} with a static server to open this hosted export`
585
+ : `open ${join(summary.outDir, "index.html")} — or host the directory anywhere static`);
586
+ } finally {
587
+ store.close();
588
+ }
589
+ }
590
+
591
+ interface UiFlags {
592
+ runs?: string;
593
+ port: string;
594
+ watch: boolean;
595
+ db: string;
596
+ results: string;
597
+ }
598
+
599
+ async function commandUi(args: string[]): Promise<void> {
600
+ let values: UiFlags;
601
+ try {
602
+ ({ values } = parseArgs({
603
+ args,
604
+ options: {
605
+ runs: { type: "string" },
606
+ port: { type: "string", default: "4173" },
607
+ watch: { type: "boolean", default: false },
608
+ db: { type: "string", default: "results.db" },
609
+ results: { type: "string", default: "results" },
610
+ },
611
+ }));
612
+ } catch (err) {
613
+ fail(`ui: ${err instanceof Error ? err.message : String(err)}\n\n${USAGE}`);
614
+ }
615
+ const port = Number(values.port);
616
+ if (!Number.isInteger(port) || port < 0 || port > 65535) fail(`ui: --port must be 0-65535, got "${values.port}"`);
617
+
618
+ const bundleDir = mustUiBundleDir();
619
+ const requested = collectRunIds([], values.runs);
620
+ const outDir = mkdtempSync(join(tmpdir(), "quickstudy-ui-"));
621
+
622
+ const doExport = async (): Promise<void> => {
623
+ const store = new ResultsStore(values.db);
624
+ try {
625
+ const runIds = requested.length > 0 ? requested : store.listRuns().map((run) => run.id);
626
+ if (runIds.length === 0) {
627
+ console.warn(
628
+ "quickstudy: this results database has no runs yet — serving the empty state.\n" +
629
+ " Get started: quickstudy run --eval <id> --experiment <id>",
630
+ );
631
+ }
632
+ const summary = await exportSite({ store, resultsDir: values.results, runIds, outDir, bundleDir, mode: "hosted" });
633
+ console.log(`exported ${summary.runs} run(s), ${summary.attempts} attempt(s) -> ${outDir}`);
634
+ } finally {
635
+ store.close();
636
+ }
637
+ };
638
+
639
+ await doExport();
640
+ const server = serveSite(outDir, port);
641
+ console.log(`quickstudy ui: ${server.url} (Ctrl-C to stop)`);
642
+
643
+ if (values.watch) {
644
+ // WAL mode writes land in <db>-wal, so watch the directory and filter by
645
+ // the db basename prefix; debounce bursts into one re-export.
646
+ const dbDir = dirname(resolve(values.db));
647
+ const dbName = basename(values.db);
648
+ let timer: ReturnType<typeof setTimeout> | null = null;
649
+ watch(dbDir, (_event, fileName) => {
650
+ if (fileName === null || !fileName.startsWith(dbName)) return;
651
+ if (timer !== null) clearTimeout(timer);
652
+ timer = setTimeout(() => {
653
+ doExport().catch((err: unknown) => {
654
+ console.warn(`quickstudy ui: re-export failed: ${err instanceof Error ? err.message : String(err)}`);
655
+ });
656
+ }, 400);
657
+ });
658
+ console.log(`watching ${values.db} — the site re-exports on change`);
659
+ }
660
+
661
+ // Serve until interrupted.
662
+ await new Promise(() => {});
663
+ }
664
+
665
+ interface ReportFlags {
666
+ json: boolean;
667
+ strict: boolean;
668
+ latest: boolean;
669
+ db: string;
670
+ results: string;
671
+ }
672
+
673
+ interface RunFlags {
674
+ resume?: string;
675
+ concurrency?: string;
676
+ seed?: string;
677
+ "retry-unfinished": boolean;
678
+ "provision-ms"?: string;
679
+ "agent-ms"?: string;
680
+ "score-ms"?: string;
681
+ "export-ms"?: string;
682
+ "cleanup-ms"?: string;
683
+ eval?: string[];
684
+ experiment?: string[];
685
+ "evals-root": string;
686
+ "experiments-root": string;
687
+ framework?: string[];
688
+ suite?: string[];
689
+ trials: string;
690
+ "egress-proxy": boolean;
691
+ db: string;
692
+ results: string;
693
+ }
694
+
695
+ function parseRunFlags(args: string[]): RunFlags {
696
+ const { values } = parseArgs({
697
+ args,
698
+ options: {
699
+ resume: { type: "string" },
700
+ concurrency: { type: "string" },
701
+ seed: { type: "string" },
702
+ "retry-unfinished": { type: "boolean", default: false },
703
+ "provision-ms": { type: "string" }, "agent-ms": { type: "string" },
704
+ "score-ms": { type: "string" }, "export-ms": { type: "string" }, "cleanup-ms": { type: "string" },
705
+ eval: { type: "string", multiple: true },
706
+ experiment: { type: "string", multiple: true },
707
+ "evals-root": { type: "string", default: "evals" },
708
+ "experiments-root": { type: "string", default: "experiments" },
709
+ framework: { type: "string", multiple: true },
710
+ suite: { type: "string", multiple: true },
711
+ trials: { type: "string", default: "3" },
712
+ // Off by default until burned in; it becomes the default later, at
713
+ // which point --no-egress-proxy preserves the direct-network mode.
714
+ "egress-proxy": { type: "boolean", default: false },
715
+ db: { type: "string", default: "results.db" },
716
+ results: { type: "string", default: "results" },
717
+ },
718
+ });
719
+ return values;
720
+ }
721
+
722
+ async function main(): Promise<void> {
723
+ const [command, ...rest] = process.argv.slice(2);
724
+ try {
725
+ if (command !== undefined && command !== "images" && helpRequested(rest)) {
726
+ const help = COMMAND_HELP[command];
727
+ if (help !== undefined) {
728
+ console.log(help);
729
+ return;
730
+ }
731
+ }
732
+ switch (command) {
733
+ case "validate":
734
+ await commandValidate(rest);
735
+ return;
736
+ case "run":
737
+ await commandRun(rest);
738
+ return;
739
+ case "report":
740
+ await commandReport(rest);
741
+ return;
742
+ case "diagnose":
743
+ await commandDiagnose(rest);
744
+ return;
745
+ case "clean":
746
+ await commandClean();
747
+ return;
748
+ case "images":
749
+ await commandImages(rest);
750
+ return;
751
+ case "export":
752
+ await commandExport(rest);
753
+ return;
754
+ case "ui":
755
+ await commandUi(rest);
756
+ return;
757
+ case "version":
758
+ case "--version":
759
+ console.log(EMBEDDED_BUILD_INFO.version);
760
+ return;
761
+ case undefined:
762
+ case "help":
763
+ case "--help":
764
+ case "-h":
765
+ console.log(USAGE);
766
+ return;
767
+ default:
768
+ fail(`unknown command "${command}"\n\n${USAGE}`);
769
+ }
770
+ } catch (err) {
771
+ if (
772
+ err instanceof EvalLoadError ||
773
+ err instanceof ExperimentLoadError ||
774
+ err instanceof MissingProviderKeyError ||
775
+ err instanceof InvalidProviderKeyError ||
776
+ err instanceof ReportError ||
777
+ err instanceof ExportError ||
778
+ err instanceof DiagnoseError ||
779
+ err instanceof ModelOutputError
780
+ ) {
781
+ fail(err.message);
782
+ }
783
+ throw err;
784
+ }
785
+ }
786
+
787
+ await main();