@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
@@ -0,0 +1,290 @@
1
+ /**
2
+ * Diagnosis orchestration: load one run's persisted evidence, select the
3
+ * diagnosable pairs, bundle harness-selected excerpts, ask the analyst per
4
+ * pair, map index-based findings back to attempt ids, derive confidence from
5
+ * rules, and write `results/<runId>/diagnosis.json` beside report.json.
6
+ *
7
+ * Everything reads the STORE (rows + artifacts) — never consumer experiment
8
+ * modules, never `src/report/` output. The model client is injected; the
9
+ * Anthropic-backed client is constructed only in the CLI command. Per-pair
10
+ * model failures land in `errors[]` and the artifact is still written —
11
+ * evidence of a failure is never lost.
12
+ */
13
+
14
+ import { mkdirSync, writeFileSync } from "node:fs";
15
+ import { join } from "node:path";
16
+ import { requestValidated, type StructuredModelClient } from "../llm.ts";
17
+ import type { AttemptRecord, ResultsStore, RunRecord } from "../store/db.ts";
18
+ import {
19
+ excerptDiff,
20
+ excerptTranscript,
21
+ readArtifact,
22
+ } from "./excerpt.ts";
23
+ import {
24
+ DIAGNOSE_JSON_SCHEMA,
25
+ DIAGNOSE_MAX_TOKENS,
26
+ DIAGNOSE_SYSTEM,
27
+ diagnoseResponseSchema,
28
+ renderPairPrompt,
29
+ type BundleAttempt,
30
+ type PairBundle,
31
+ } from "./prompt.ts";
32
+ import { selectDiagnosablePairs, type DiagnosablePair } from "./select.ts";
33
+ import {
34
+ DIAGNOSIS_DISCLAIMER,
35
+ DIAGNOSIS_PROMPT_VERSION,
36
+ DIAGNOSIS_SCHEMA_VERSION,
37
+ DiagnoseError,
38
+ type DiagnosisConfidence,
39
+ type DiagnosisEvidence,
40
+ type DiagnosisFinding,
41
+ type DiagnosisJson,
42
+ type PairDiagnosisError,
43
+ } from "./types.ts";
44
+
45
+ /** Analyst model default; overridable via QUICKSTUDY_ANALYST_MODEL (deprecation resilience). */
46
+ export const DEFAULT_ANALYST_MODEL = "claude-sonnet-5";
47
+
48
+ export function analystModelFromEnv(env: Record<string, string | undefined> = process.env): string {
49
+ const model = env["QUICKSTUDY_ANALYST_MODEL"];
50
+ return model !== undefined && model !== "" ? model : DEFAULT_ANALYST_MODEL;
51
+ }
52
+
53
+ /** Canonical location of a run's diagnosis.json (mirrors reportJsonPath). */
54
+ export function diagnosisJsonPath(resultsDir: string, runId: string): string {
55
+ return join(resultsDir, runId, "diagnosis.json");
56
+ }
57
+
58
+ /** Write diagnosis.json into the run's results dir. Returns the path. */
59
+ export function writeDiagnosisJson(diagnosis: DiagnosisJson, resultsDir: string): string {
60
+ const dir = join(resultsDir, diagnosis.run_id);
61
+ mkdirSync(dir, { recursive: true });
62
+ const path = diagnosisJsonPath(resultsDir, diagnosis.run_id);
63
+ writeFileSync(path, `${JSON.stringify(diagnosis, null, 2)}\n`, "utf8");
64
+ return path;
65
+ }
66
+
67
+ function mustResolveRun(store: ResultsStore, runId: string | undefined): RunRecord {
68
+ if (runId === undefined) {
69
+ const latest = store.latestRun();
70
+ if (latest === null) {
71
+ throw new DiagnoseError(
72
+ "this results database has no runs yet — run `quickstudy run --eval <id> --experiment <id>` first",
73
+ );
74
+ }
75
+ return latest;
76
+ }
77
+ const run = store.getRun(runId);
78
+ if (run) return run;
79
+ const known = store
80
+ .listRuns()
81
+ .slice(0, 5)
82
+ .map((candidate) => ` ${candidate.id} (started ${new Date(candidate.startedAt).toISOString()})`);
83
+ throw new DiagnoseError(
84
+ `no run "${runId}" in this results database${known.length > 0 ? `; most recent runs:\n${known.join("\n")}` : ""}`,
85
+ );
86
+ }
87
+
88
+ function attemptLabel(cls: "failed" | "error" | "incomplete" | "passed", contrast: boolean): string {
89
+ if (contrast) return "passing contrast";
90
+ if (cls === "error") return "errored";
91
+ return cls;
92
+ }
93
+
94
+ /**
95
+ * Read a bundle attempt's artifacts and excerpt them. Paths derive from the
96
+ * results dir layout (the report's egress-denials precedent) with the stored
97
+ * ref as fallback, and only refs the runner actually recorded are read.
98
+ */
99
+ function bundleAttemptFor(
100
+ resultsDir: string,
101
+ runId: string,
102
+ selection: { attempt: AttemptRecord; class: "failed" | "error" | "incomplete" | "passed"; contrast: boolean },
103
+ index: number,
104
+ ): BundleAttempt {
105
+ const { attempt } = selection;
106
+ const bundleAttempt: BundleAttempt = {
107
+ index,
108
+ attemptId: attempt.id,
109
+ label: attemptLabel(selection.class, selection.contrast),
110
+ };
111
+ if (attempt.transcriptRef !== null) {
112
+ const raw =
113
+ readArtifact(join(resultsDir, runId, attempt.id, "transcript.jsonl")) ?? readArtifact(attempt.transcriptRef);
114
+ if (raw !== undefined && raw !== "") bundleAttempt.transcript = excerptTranscript(raw);
115
+ }
116
+ if (attempt.diffRef !== null) {
117
+ const raw = readArtifact(join(resultsDir, runId, attempt.id, "diff.patch")) ?? readArtifact(attempt.diffRef);
118
+ if (raw !== undefined && raw !== "") bundleAttempt.diff = excerptDiff(raw);
119
+ }
120
+ return bundleAttempt;
121
+ }
122
+
123
+ /** Assemble one pair's prompt bundle. Exported for tests. */
124
+ export function buildPairBundle(options: {
125
+ run: RunRecord;
126
+ resultsDir: string;
127
+ pair: DiagnosablePair;
128
+ }): PairBundle {
129
+ const { run, resultsDir, pair } = options;
130
+ const treatment = run.manifest.experiments[pair.experimentId]?.components.treatment ?? null;
131
+ const surfaces = run.config.surfaces?.[pair.experimentId];
132
+ return {
133
+ evalId: pair.evalId,
134
+ experimentId: pair.experimentId,
135
+ treatment,
136
+ tallies: pair.tallies,
137
+ failedChecks: pair.failedChecks,
138
+ ...(surfaces !== undefined ? { surfaces } : {}),
139
+ attempts: pair.bundle.map((selection, position) => bundleAttemptFor(resultsDir, run.id, selection, position + 1)),
140
+ };
141
+ }
142
+
143
+ /**
144
+ * Map the model's index-only references back to attempt ids + the excerpt
145
+ * windows the model was actually shown. Out-of-range indexes are dropped; a
146
+ * finding whose indexes all fail to map falls back to the bundle's first
147
+ * failing attempt's excerpt — degrade, never fabricate.
148
+ */
149
+ export function mapEvidence(bundle: PairBundle, attemptIndexes: number[]): DiagnosisEvidence[] {
150
+ const mapped: DiagnosisEvidence[] = [];
151
+ const seen = new Set<string>();
152
+ for (const index of attemptIndexes) {
153
+ const attempt = bundle.attempts.find((candidate) => candidate.index === index);
154
+ if (!attempt) continue;
155
+ const excerpt = attempt.transcript ?? attempt.diff;
156
+ if (!excerpt) continue;
157
+ if (seen.has(attempt.attemptId)) continue;
158
+ seen.add(attempt.attemptId);
159
+ mapped.push({
160
+ attempt_id: attempt.attemptId,
161
+ artifact: attempt.transcript ? "transcript" : "diff",
162
+ lines: excerpt.lines,
163
+ });
164
+ }
165
+ if (mapped.length === 0) {
166
+ const first = bundle.attempts.find((attempt) => attempt.label !== "passing contrast" && (attempt.transcript ?? attempt.diff));
167
+ const excerpt = first?.transcript ?? first?.diff;
168
+ if (first && excerpt) {
169
+ mapped.push({
170
+ attempt_id: first.attemptId,
171
+ artifact: first.transcript ? "transcript" : "diff",
172
+ lines: excerpt.lines,
173
+ });
174
+ }
175
+ }
176
+ return mapped;
177
+ }
178
+
179
+ /**
180
+ * Rule-derived confidence — never asked of the model:
181
+ * high = pair has zero passes and >= 2 bundled attempts mapped as evidence;
182
+ * medium = zero passes with 1 mapped attempt, or flaky with >= 2;
183
+ * low = otherwise.
184
+ */
185
+ export function deriveConfidence(pair: DiagnosablePair, evidence: DiagnosisEvidence[]): DiagnosisConfidence {
186
+ const mappedAttempts = new Set(evidence.map((entry) => entry.attempt_id)).size;
187
+ const zeroPasses = pair.tallies.passed === 0;
188
+ if (zeroPasses && mappedAttempts >= 2) return "high";
189
+ if ((zeroPasses && mappedAttempts === 1) || (pair.flaky && mappedAttempts >= 2)) return "medium";
190
+ return "low";
191
+ }
192
+
193
+ export interface RunDiagnoseOptions {
194
+ store: ResultsStore;
195
+ /** Artifacts root (the harness `--results` dir) — transcripts and diffs live here. */
196
+ resultsDir: string;
197
+ /** Omit to diagnose the most recent run (`--latest`). */
198
+ runId?: string;
199
+ /** Injected model client — the CLI is the only place the Anthropic client is constructed. */
200
+ client: StructuredModelClient;
201
+ /** Overrides the analyst model (default: QUICKSTUDY_ANALYST_MODEL or the pinned default). */
202
+ model?: string;
203
+ /** Overrides the `generated_at` stamp — fixture seeding only. */
204
+ generatedAt?: string;
205
+ /** Progress lines (pair counts) — the CLI passes console.error. */
206
+ log?: (line: string) => void;
207
+ }
208
+
209
+ /**
210
+ * Diagnose one run and write diagnosis.json. Throws DiagnoseError only when
211
+ * the run cannot be resolved; per-pair failures are recorded in `errors[]`
212
+ * and the artifact is still written.
213
+ */
214
+ export async function runDiagnose(options: RunDiagnoseOptions): Promise<DiagnosisJson> {
215
+ const { store, resultsDir, client } = options;
216
+ const log = options.log ?? ((): void => {});
217
+ const model = options.model ?? analystModelFromEnv();
218
+
219
+ const run = mustResolveRun(store, options.runId);
220
+ const attempts = store.listAttempts(run.id);
221
+ const selection = selectDiagnosablePairs(attempts);
222
+ log(
223
+ `diagnose: run ${run.id} — ${selection.pairs.length} diagnosable pair(s), ` +
224
+ `${selection.skipped} passing pair(s) skipped`,
225
+ );
226
+
227
+ const findings: DiagnosisFinding[] = [];
228
+ const errors: PairDiagnosisError[] = [];
229
+
230
+ // Sequential by design: a sweep is at most a handful of requests and the
231
+ // SDK owns transport retries — no concurrency machinery.
232
+ for (const pair of selection.pairs) {
233
+ const bundle = buildPairBundle({ run, resultsDir, pair });
234
+ if (bundle.attempts.every((attempt) => attempt.transcript === undefined && attempt.diff === undefined)) {
235
+ errors.push({
236
+ eval_id: pair.evalId,
237
+ experiment_id: pair.experimentId,
238
+ error: "no readable transcript or diff artifacts for any bundled attempt",
239
+ });
240
+ continue;
241
+ }
242
+ try {
243
+ const response = await requestValidated(
244
+ client,
245
+ {
246
+ model,
247
+ system: DIAGNOSE_SYSTEM,
248
+ user: renderPairPrompt(bundle),
249
+ maxTokens: DIAGNOSE_MAX_TOKENS,
250
+ schema: DIAGNOSE_JSON_SCHEMA,
251
+ },
252
+ diagnoseResponseSchema,
253
+ );
254
+ for (const draft of response.findings) {
255
+ const evidence = mapEvidence(bundle, draft.attempt_indexes);
256
+ if (evidence.length === 0) continue; // nothing mappable at all — unusable
257
+ findings.push({
258
+ eval_id: pair.evalId,
259
+ experiment_id: pair.experimentId,
260
+ target: draft.target,
261
+ claim: draft.claim,
262
+ suggested_change: draft.suggested_change,
263
+ confidence: deriveConfidence(pair, evidence),
264
+ evidence,
265
+ });
266
+ }
267
+ } catch (err) {
268
+ errors.push({
269
+ eval_id: pair.evalId,
270
+ experiment_id: pair.experimentId,
271
+ error: err instanceof Error ? err.message : String(err),
272
+ });
273
+ }
274
+ }
275
+
276
+ const diagnosis: DiagnosisJson = {
277
+ schema_version: DIAGNOSIS_SCHEMA_VERSION,
278
+ run_id: run.id,
279
+ generated_at: options.generatedAt ?? new Date().toISOString(),
280
+ model: { requested: model, resolved: client.resolvedModel?.() ?? null },
281
+ prompt_version: DIAGNOSIS_PROMPT_VERSION,
282
+ disclaimer: DIAGNOSIS_DISCLAIMER,
283
+ pairs_diagnosed: selection.pairs.length - errors.length,
284
+ pairs_skipped: selection.skipped,
285
+ findings,
286
+ errors,
287
+ };
288
+ writeDiagnosisJson(diagnosis, resultsDir);
289
+ return diagnosis;
290
+ }
@@ -0,0 +1,110 @@
1
+ /**
2
+ * Pair selection for diagnosis: which (eval × experiment) pairs are worth
3
+ * model spend, and which attempts feed each pair's evidence bundle.
4
+ *
5
+ * Pair counts and flaky/check aggregation reuse the report's `collectPairs`
6
+ * helper so the two views cannot drift.
7
+ */
8
+
9
+ import { collectPairs, type ReportPair } from "../report/report.ts";
10
+ import type { AttemptRecord } from "../store/db.ts";
11
+
12
+ export type AttemptClass = "passed" | "failed" | "error" | "incomplete";
13
+
14
+ /** At most this many non-passing attempts feed one pair bundle. */
15
+ export const MAX_BUNDLE_ATTEMPTS = 3;
16
+
17
+ /** Report-semantics attempt classification (see module docblock). */
18
+ export function classifyAttempt(attempt: AttemptRecord): AttemptClass {
19
+ if (attempt.status === "incomplete") return "incomplete";
20
+ if (attempt.status === "error") return "error";
21
+ return attempt.result?.passed ? "passed" : "failed";
22
+ }
23
+
24
+ export type PairTallies = Pick<ReportPair, "total" | "passed" | "failed" | "errors" | "incomplete">;
25
+
26
+ /** One attempt selected into a pair's bundle. */
27
+ export interface BundleSelection {
28
+ attempt: AttemptRecord;
29
+ class: AttemptClass;
30
+ /** True for the single passing attempt included as flaky-pair contrast. */
31
+ contrast: boolean;
32
+ }
33
+
34
+ /** A pair with at least one failed/errored/incomplete attempt. */
35
+ export interface DiagnosablePair {
36
+ evalId: string;
37
+ experimentId: string;
38
+ tallies: PairTallies;
39
+ flaky: boolean;
40
+ /** Check names that failed at least once across the pair's attempts, sorted. */
41
+ failedChecks: string[];
42
+ /** ≤ MAX_BUNDLE_ATTEMPTS non-passing attempts (+ 1 passing contrast when flaky). */
43
+ bundle: BundleSelection[];
44
+ }
45
+
46
+ export interface PairSelection {
47
+ pairs: DiagnosablePair[];
48
+ /** Passing-only pairs — skipped, never sent to the model. */
49
+ skipped: number;
50
+ }
51
+
52
+ /** Bundle ordering: failed first, then errors, then incomplete. */
53
+ const CLASS_ORDER: Record<Exclude<AttemptClass, "passed">, number> = {
54
+ failed: 0,
55
+ error: 1,
56
+ incomplete: 2,
57
+ };
58
+
59
+ /**
60
+ * Group attempts into pairs, classify, and select each diagnosable pair's
61
+ * bundle. Deterministic: pairs sort by eval id then experiment id (the
62
+ * report's order); within a class, attempts sort by ULID (id) order.
63
+ */
64
+ export function selectDiagnosablePairs(attempts: AttemptRecord[]): PairSelection {
65
+ const byPair = new Map<string, AttemptRecord[]>();
66
+ for (const attempt of attempts) {
67
+ const key = `${attempt.evalId}|${attempt.experimentId}`;
68
+ const group = byPair.get(key);
69
+ if (group) group.push(attempt);
70
+ else byPair.set(key, [attempt]);
71
+ }
72
+
73
+ const pairs: DiagnosablePair[] = [];
74
+ let skipped = 0;
75
+
76
+ for (const group of byPair.values()) {
77
+ const report = collectPairs(group)[0]!;
78
+ if (report.failed + report.errors + report.incomplete === 0) {
79
+ skipped += 1;
80
+ continue;
81
+ }
82
+
83
+ const classified = group
84
+ .map((attempt) => ({ attempt, class: classifyAttempt(attempt) }))
85
+ .sort((a, b) => a.attempt.id.localeCompare(b.attempt.id));
86
+ const nonPassing = classified
87
+ .filter((entry): entry is { attempt: AttemptRecord; class: Exclude<AttemptClass, "passed"> } => entry.class !== "passed")
88
+ .sort((a, b) => CLASS_ORDER[a.class] - CLASS_ORDER[b.class] || a.attempt.id.localeCompare(b.attempt.id));
89
+ const bundle: BundleSelection[] = nonPassing
90
+ .slice(0, MAX_BUNDLE_ATTEMPTS)
91
+ .map((entry) => ({ attempt: entry.attempt, class: entry.class, contrast: false }));
92
+
93
+ if (report.flaky) {
94
+ const passing = classified.find((entry) => entry.class === "passed");
95
+ if (passing) bundle.push({ attempt: passing.attempt, class: "passed", contrast: true });
96
+ }
97
+
98
+ pairs.push({
99
+ evalId: report.eval_id,
100
+ experimentId: report.experiment_id,
101
+ tallies: report,
102
+ flaky: report.flaky,
103
+ failedChecks: report.checks.filter((check) => check.passed < check.total).map((check) => check.name),
104
+ bundle,
105
+ });
106
+ }
107
+
108
+ pairs.sort((a, b) => a.evalId.localeCompare(b.evalId) || a.experimentId.localeCompare(b.experimentId));
109
+ return { pairs, skipped };
110
+ }
@@ -0,0 +1,88 @@
1
+ /**
2
+ * The diagnosis.json contract: LLM-drafted, harness-evidenced hypotheses for
3
+ * one run's failing (eval × experiment) pairs.
4
+ *
5
+ * diagnosis.json is a SEPARATE artifact from report.json, deliberately exempt
6
+ * from the report's BANNED_LANGUAGE guard — it self-labels with
7
+ * {@link DIAGNOSIS_DISCLAIMER} instead, and nothing under `src/report/`
8
+ * reads or writes it. Evidence is harness-owned: every finding's evidence
9
+ * points at a deterministic excerpt window the harness selected; the model
10
+ * only ever referenced attempts by 1-based index.
11
+ */
12
+
13
+ /** Loud, CLI-friendly diagnose failures (unknown run, empty evidence, ...). */
14
+ export class DiagnoseError extends Error {
15
+ constructor(message: string) {
16
+ super(message);
17
+ this.name = "DiagnoseError";
18
+ }
19
+ }
20
+
21
+ export const DIAGNOSIS_SCHEMA_VERSION = 1;
22
+
23
+ /** Bumped whenever the system/user prompt text changes shape. */
24
+ export const DIAGNOSIS_PROMPT_VERSION = 1;
25
+
26
+ /** Fixed self-labeling carried inside every diagnosis.json. */
27
+ export const DIAGNOSIS_DISCLAIMER =
28
+ "These findings are model-drafted hypotheses, not verdicts. Scope is this single run: " +
29
+ "no cross-run claims, no attribution beyond the cited evidence. Each evidence link names a " +
30
+ "harness-selected excerpt (artifact + 1-indexed line range) — verify the cited lines before acting.";
31
+
32
+ /**
33
+ * The generic surface taxonomy a finding can implicate. Concrete surface
34
+ * names (hosts, commands, server names) come from the run's recorded config —
35
+ * the taxonomy itself stays consumer-neutral.
36
+ */
37
+ export type DiagnosisTarget = "docs" | "mcp" | "cli" | "sdk" | "environment" | "eval-defect";
38
+
39
+ export const DIAGNOSIS_TARGETS: readonly DiagnosisTarget[] = [
40
+ "docs",
41
+ "mcp",
42
+ "cli",
43
+ "sdk",
44
+ "environment",
45
+ "eval-defect",
46
+ ];
47
+
48
+ /** Rule-derived from pair shape + mapped evidence — never model-asserted. */
49
+ export type DiagnosisConfidence = "high" | "medium" | "low";
50
+
51
+ export interface DiagnosisEvidence {
52
+ attempt_id: string;
53
+ artifact: "transcript" | "diff";
54
+ /** 1-indexed inclusive line range inside the artifact — always a harness-selected window. */
55
+ lines: [number, number];
56
+ }
57
+
58
+ export interface DiagnosisFinding {
59
+ eval_id: string;
60
+ experiment_id: string;
61
+ target: DiagnosisTarget;
62
+ claim: string;
63
+ suggested_change: string;
64
+ confidence: DiagnosisConfidence;
65
+ evidence: DiagnosisEvidence[];
66
+ }
67
+
68
+ /** A pair whose analysis failed (model error after retries) — recorded, never silently dropped. */
69
+ export interface PairDiagnosisError {
70
+ eval_id: string;
71
+ experiment_id: string;
72
+ error: string;
73
+ }
74
+
75
+ export interface DiagnosisJson {
76
+ schema_version: typeof DIAGNOSIS_SCHEMA_VERSION;
77
+ run_id: string;
78
+ generated_at: string;
79
+ /** Requested model (pin or env override) and the model id the API reported back. */
80
+ model: { requested: string; resolved: string | null };
81
+ prompt_version: typeof DIAGNOSIS_PROMPT_VERSION;
82
+ /** Fixed self-labeling: hypotheses with evidence links, not verdicts; single-sweep scope. */
83
+ disclaimer: string;
84
+ pairs_diagnosed: number;
85
+ pairs_skipped: number;
86
+ findings: DiagnosisFinding[];
87
+ errors: PairDiagnosisError[];
88
+ }
@@ -0,0 +1,173 @@
1
+ /**
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
4
+ * purpose — one visible expansion (a `frameworks:` frontmatter list yields
5
+ * one eval per framework, id `<dirname>-<framework>`), no placeholders, no
6
+ * binding — and loud: the first error aborts the scan.
7
+ */
8
+
9
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
10
+ import { basename, dirname, join, relative, resolve, sep } from "node:path";
11
+ import { pathToFileURL } from "node:url";
12
+ import { EvalLoadError, parsePrompt } from "./prompt.ts";
13
+ import type { EvalScorer, LoadedEval } from "./types.ts";
14
+
15
+ /** One collected eval; `framework` is set for variant-expanded entries. */
16
+ interface CollectedEval {
17
+ loaded: LoadedEval;
18
+ framework?: string;
19
+ }
20
+
21
+ /**
22
+ * Discover every eval under `root`. A subdirectory containing a PROMPT.md is
23
+ * an eval; it must also carry an EVAL.ts, its frontmatter `id` must equal the
24
+ * directory name, and ids must be unique across the root.
25
+ *
26
+ * A `frameworks:` frontmatter list expands the directory into one eval per
27
+ * framework: id `<dirname>-<framework>`, `metadata.framework` set, prompt and
28
+ * scorer shared verbatim, starting state resolved per framework from
29
+ * `local/<framework>` — or `<local:>/<framework>` when the eval points at a
30
+ * shared fixture root. Everything downstream consumes the expanded
31
+ * {@link LoadedEval}s exactly like single-directory ones.
32
+ */
33
+ export function discoverEvals(root: string): LoadedEval[] {
34
+ const rootDir = resolve(root);
35
+ if (!existsSync(rootDir) || !statSync(rootDir).isDirectory()) {
36
+ throw new Error(`evals root "${root}" is not a directory`);
37
+ }
38
+
39
+ const collected: CollectedEval[] = [];
40
+ for (const entry of readdirSync(rootDir, { withFileTypes: true }).sort((a, b) => a.name.localeCompare(b.name))) {
41
+ if (!entry.isDirectory()) continue;
42
+ const dir = join(rootDir, entry.name);
43
+ const promptPath = join(dir, "PROMPT.md");
44
+ if (!existsSync(promptPath)) continue;
45
+
46
+ const { metadata, promptBody, frameworks, local } = parsePrompt(readFileSync(promptPath, "utf8"), promptPath);
47
+
48
+ const scorerPath = join(dir, "EVAL.ts");
49
+ if (!existsSync(scorerPath)) {
50
+ throw new EvalLoadError(promptPath, 1, `eval "${metadata.id}" has no EVAL.ts next to its PROMPT.md (expected ${scorerPath})`);
51
+ }
52
+
53
+ if (frameworks === undefined) {
54
+ const localDir = join(dir, "local");
55
+ const hasLocal = existsSync(localDir) && statSync(localDir).isDirectory();
56
+ collected.push({
57
+ loaded: { metadata, dir, promptPath, promptBody, scorerPath, localDir: hasLocal ? localDir : null },
58
+ });
59
+ continue;
60
+ }
61
+
62
+ // Variant form: one eval per framework. The starting-state base is the
63
+ // conventional local/ directory or the explicit `local:` path (shared
64
+ // fixtures), resolved from the eval directory. hashTree receives the
65
+ // resolved per-framework root, so unchanged fixture trees keep
66
+ // byte-stable hashes wherever they live.
67
+ const localBase = local === undefined ? join(dir, "local") : resolve(dir, local);
68
+ const hasBase = existsSync(localBase) && statSync(localBase).isDirectory();
69
+ if (local !== undefined) {
70
+ const rootParent = dirname(rootDir);
71
+ const escape = relative(rootParent, localBase);
72
+ if (escape === ".." || escape.startsWith(`..${sep}`)) {
73
+ throw new EvalLoadError(
74
+ promptPath,
75
+ 1,
76
+ `"local" path ${JSON.stringify(local)} resolves to ${localBase}, ` +
77
+ `outside the evals root's parent (${rootParent})`,
78
+ );
79
+ }
80
+ if (!hasBase) {
81
+ throw new EvalLoadError(
82
+ promptPath,
83
+ 1,
84
+ `"local" path ${JSON.stringify(local)} resolves to ${localBase}, which is not a directory`,
85
+ );
86
+ }
87
+ }
88
+ // No starting state at all (no local/ and no `local:`) is valid — every
89
+ // variant starts from an empty workspace, like a tools-only eval.
90
+ for (const framework of frameworks) {
91
+ let localDir: string | null = null;
92
+ if (hasBase) {
93
+ const frameworkDir = join(localBase, framework);
94
+ if (!existsSync(frameworkDir) || !statSync(frameworkDir).isDirectory()) {
95
+ throw new EvalLoadError(
96
+ promptPath,
97
+ 1,
98
+ `eval "${metadata.id}" lists framework "${framework}" but has no starting state for it ` +
99
+ `(expected ${frameworkDir})`,
100
+ );
101
+ }
102
+ localDir = frameworkDir;
103
+ }
104
+ collected.push({
105
+ loaded: {
106
+ metadata: { ...metadata, id: `${metadata.id}-${framework}`, framework },
107
+ dir,
108
+ promptPath,
109
+ promptBody,
110
+ scorerPath,
111
+ localDir,
112
+ },
113
+ framework,
114
+ });
115
+ }
116
+ }
117
+
118
+ // Duplicates are checked BEFORE the id/dirname match so two directories
119
+ // claiming one id — or a variant expansion colliding with a literal
120
+ // directory — fail with both paths named, not with a mismatch message
121
+ // that points at only one of them.
122
+ const byId = new Map<string, CollectedEval>();
123
+ for (const entry of collected) {
124
+ const existing = byId.get(entry.loaded.metadata.id);
125
+ if (existing) {
126
+ throw new EvalLoadError(
127
+ entry.loaded.promptPath,
128
+ 1,
129
+ `duplicate eval id "${entry.loaded.metadata.id}" — also declared by ${existing.loaded.promptPath}`,
130
+ );
131
+ }
132
+ byId.set(entry.loaded.metadata.id, entry);
133
+ }
134
+ for (const entry of collected) {
135
+ const dirName = basename(entry.loaded.dir);
136
+ const expected = entry.framework === undefined ? dirName : `${dirName}-${entry.framework}`;
137
+ if (entry.loaded.metadata.id !== expected) {
138
+ throw new EvalLoadError(
139
+ entry.loaded.promptPath,
140
+ 1,
141
+ entry.framework === undefined
142
+ ? `frontmatter id "${entry.loaded.metadata.id}" must equal the directory name "${dirName}" (prevents silent aliasing)`
143
+ : `expanded eval id "${entry.loaded.metadata.id}" must equal "<dirname>-<framework>" ("${expected}") — ` +
144
+ `a variant eval's frontmatter id must equal its directory name (prevents silent aliasing)`,
145
+ );
146
+ }
147
+ }
148
+
149
+ return collected.map((entry) => entry.loaded).sort((a, b) => a.metadata.id.localeCompare(b.metadata.id));
150
+ }
151
+
152
+ /**
153
+ * Import an eval's scorer module and validate its default export is a
154
+ * function. Called by `validate` (so a broken EVAL.ts fails before any
155
+ * attempt runs) and by the runner at score time.
156
+ */
157
+ export async function importEvalScorer(scorerPath: string): Promise<EvalScorer> {
158
+ let module: Record<string, unknown>;
159
+ try {
160
+ module = (await import(pathToFileURL(resolve(scorerPath)).href)) as Record<string, unknown>;
161
+ } catch (err) {
162
+ throw new EvalLoadError(scorerPath, 1, `EVAL.ts failed to import: ${err instanceof Error ? err.message : String(err)}`);
163
+ }
164
+ const scorer = module["default"];
165
+ if (typeof scorer !== "function") {
166
+ throw new EvalLoadError(
167
+ scorerPath,
168
+ 1,
169
+ `EVAL.ts must default-export an async scorer function (ctx) => EvalResult; got ${typeof scorer}`,
170
+ );
171
+ }
172
+ return scorer as EvalScorer;
173
+ }