blume 1.1.3 → 1.2.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.
Files changed (82) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/README.md +1 -1
  3. package/dist/cli/index.js +1473 -149
  4. package/dist/cli/index.js.map +47 -36
  5. package/dist/types/core/config-input.d.ts +18 -0
  6. package/dist/types/core/config.d.ts +4 -0
  7. package/dist/types/core/data.d.ts +3 -0
  8. package/dist/types/core/schema.d.ts +132 -17
  9. package/dist/types/core/types.d.ts +5 -3
  10. package/dist/types/openapi/references.d.ts +6 -0
  11. package/docs/advanced/api-reference.mdx +27 -0
  12. package/docs/advanced/changelog.mdx +10 -0
  13. package/docs/configuration/ai.mdx +38 -2
  14. package/docs/configuration/customization.mdx +27 -0
  15. package/docs/configuration/index.mdx +5 -0
  16. package/docs/content/navigation.mdx +12 -0
  17. package/docs/reference/cli.mdx +17 -13
  18. package/docs/reference/eval.mdx +106 -0
  19. package/docs/reference/meta.ts +1 -1
  20. package/package.json +1 -1
  21. package/src/ai/agent-readability.ts +19 -1
  22. package/src/ai/llms.ts +9 -4
  23. package/src/ai/mcp/server.ts +48 -14
  24. package/src/ai/mcp/stdio.ts +35 -0
  25. package/src/astro/generate.ts +119 -48
  26. package/src/astro/templates.ts +173 -37
  27. package/src/audit/checks/duplicates.ts +15 -6
  28. package/src/audit/checks/indexability.ts +11 -2
  29. package/src/audit/checks/network.ts +22 -8
  30. package/src/audit/checks/sitemap.ts +42 -16
  31. package/src/audit/redirects.ts +12 -1
  32. package/src/audit/run.ts +13 -3
  33. package/src/audit/url.ts +21 -2
  34. package/src/cli/commands/audit.ts +21 -6
  35. package/src/cli/commands/dev.ts +19 -2
  36. package/src/cli/commands/eval.ts +291 -0
  37. package/src/cli/commands/init.ts +9 -4
  38. package/src/cli/commands/mcp-stdio.ts +36 -0
  39. package/src/cli/index.ts +4 -0
  40. package/src/cli/required-secrets.ts +1 -1
  41. package/src/components/content/AccordionItem.astro +2 -2
  42. package/src/components/content/Frame.astro +4 -1
  43. package/src/components/content/Prompt.astro +4 -1
  44. package/src/components/content/Tooltip.astro +4 -1
  45. package/src/components/content/TreeFolder.astro +1 -2
  46. package/src/components/content/Update.astro +45 -0
  47. package/src/components/islands/AskAI.astro +9 -2
  48. package/src/components/islands/ask-ai.tsx +23 -4
  49. package/src/components/islands/hooks.ts +48 -15
  50. package/src/components/layout/NavTree.astro +37 -19
  51. package/src/components/layout/ReferenceLayout.astro +4 -0
  52. package/src/components/layout/RootLayout.astro +14 -3
  53. package/src/components/layout/Search.astro +5 -1
  54. package/src/components/layout/head-scripts.ts +22 -5
  55. package/src/components/openapi/SchemaProperty.astro +3 -3
  56. package/src/core/config-input.ts +18 -0
  57. package/src/core/config.ts +4 -0
  58. package/src/core/data.ts +3 -0
  59. package/src/core/deployment-env.ts +7 -2
  60. package/src/core/graph.ts +8 -1
  61. package/src/core/i18n.ts +10 -2
  62. package/src/core/navigation.ts +16 -5
  63. package/src/core/schema.ts +51 -4
  64. package/src/core/server-features.ts +1 -1
  65. package/src/core/sources/normalize.ts +69 -8
  66. package/src/core/sources/notion.ts +4 -2
  67. package/src/core/sources/sanity.ts +5 -3
  68. package/src/core/types.ts +5 -3
  69. package/src/eval/agents.ts +340 -0
  70. package/src/eval/findings.ts +103 -0
  71. package/src/eval/prompts.ts +78 -0
  72. package/src/eval/report.ts +214 -0
  73. package/src/eval/run.ts +290 -0
  74. package/src/eval/schema.ts +124 -0
  75. package/src/markdown/code-title.ts +7 -1
  76. package/src/openapi/model.ts +31 -2
  77. package/src/openapi/references.ts +23 -2
  78. package/src/openapi/render-mdx.ts +39 -11
  79. package/src/openapi/scalar.ts +1 -0
  80. package/src/openapi/source.ts +11 -4
  81. package/src/registry/eject.ts +23 -1
  82. package/src/search/build.ts +4 -3
@@ -0,0 +1,103 @@
1
+ import type { BlumeProject } from "../core/project-graph.ts";
2
+ import type { Diagnostic } from "../core/types.ts";
3
+ import { locateQuestion } from "./schema.ts";
4
+ import type { EvalQuestion } from "./schema.ts";
5
+
6
+ const DOCS_URL = "https://useblume.dev/docs/reference/eval";
7
+
8
+ /** Where a finding should anchor when no route hint matches a page. */
9
+ export interface EvalsAnchor {
10
+ path: string;
11
+ raw: string;
12
+ }
13
+
14
+ /** The manifest route a hint names, if any. */
15
+ const hintedRoute = (question: EvalQuestion, project: BlumeProject) => {
16
+ for (const hint of question.routes) {
17
+ const route = project.manifest.routes.find(
18
+ (candidate) => candidate.path === hint
19
+ );
20
+ if (route) {
21
+ return route;
22
+ }
23
+ }
24
+ };
25
+
26
+ /**
27
+ * Warnings for route hints that name no manifest route — docs move, and a
28
+ * finding that silently anchors to the evals file instead of the page it
29
+ * used to name is a debugging session; a warning is a one-line fix.
30
+ */
31
+ export const routeFindings = (
32
+ question: EvalQuestion,
33
+ project: BlumeProject,
34
+ anchor: EvalsAnchor
35
+ ): Diagnostic[] => {
36
+ const known = new Set(project.manifest.routes.map((route) => route.path));
37
+ return question.routes
38
+ .filter((hint) => !known.has(hint))
39
+ .map((hint) => ({
40
+ code: "BLUME_EVAL_ROUTE_UNKNOWN",
41
+ docsUrl: DOCS_URL,
42
+ file: anchor.path,
43
+ line: locateQuestion(anchor.raw, question.id),
44
+ message: `Question "${question.id}" hints at route "${hint}", which matches no page.`,
45
+ severity: "warning" as const,
46
+ suggestion:
47
+ "Update the question's `routes` to the page's current route, or remove the hint.",
48
+ }));
49
+ };
50
+
51
+ /** What the finding needs to know about how the question went. */
52
+ export interface QuestionOutcome {
53
+ /** Why the run errored, when it did. */
54
+ detail?: string;
55
+ /** The expected facts the judge found absent or contradicted. */
56
+ missing: string[];
57
+ status: "error" | "fail";
58
+ }
59
+
60
+ /**
61
+ * The diagnostic for a failed or errored question, anchored to the page that
62
+ * should have answered it (via the first matching route hint) or, failing
63
+ * that, to the question's line in the evals file.
64
+ */
65
+ export const questionFinding = (
66
+ question: EvalQuestion,
67
+ outcome: QuestionOutcome,
68
+ project: BlumeProject,
69
+ anchor: EvalsAnchor
70
+ ): Diagnostic => {
71
+ const route = hintedRoute(question, project);
72
+ const site = route
73
+ ? { file: route.sourcePath, url: route.path }
74
+ : { file: anchor.path, line: locateQuestion(anchor.raw, question.id) };
75
+
76
+ if (outcome.status === "error") {
77
+ return {
78
+ code: "BLUME_EVAL_QUESTION_ERROR",
79
+ docsUrl: DOCS_URL,
80
+ message: `Eval run failed for "${question.question}"${
81
+ outcome.detail ? ` — ${outcome.detail}` : ""
82
+ }.`,
83
+ severity: question.severity,
84
+ suggestion:
85
+ "Rerun `blume eval`; if it persists, check the agent CLI installation and the failure detail.",
86
+ ...site,
87
+ };
88
+ }
89
+
90
+ const missing =
91
+ outcome.missing.length > 0
92
+ ? ` — missing: ${outcome.missing.join("; ")}`
93
+ : "";
94
+ return {
95
+ code: "BLUME_EVAL_QUESTION_FAILED",
96
+ docsUrl: DOCS_URL,
97
+ message: `Docs could not answer: "${question.question}"${missing}`,
98
+ severity: question.severity,
99
+ suggestion:
100
+ "State the missing facts on this page, then rerun `blume eval`.",
101
+ ...site,
102
+ };
103
+ };
@@ -0,0 +1,78 @@
1
+ import type { EvalQuestion } from "./schema.ts";
2
+
3
+ /**
4
+ * The reader's instructions. The agent is spawned in an empty directory with
5
+ * its file/shell/web tools disabled, so the prompt's job is to direct it at
6
+ * the MCP docs tools and forbid the one escape hatch that remains: answering
7
+ * from prior knowledge of the product.
8
+ */
9
+ export const readerPrompt = (question: EvalQuestion): string =>
10
+ `You are evaluating whether a product's documentation can answer a user's question.
11
+
12
+ Answer the question below using ONLY the connected documentation tools (search_docs, get_page, list_pages, get_navigation). Rules:
13
+ - Do not use prior knowledge about the product. Do not guess.
14
+ - Do not read files, run commands, or access the network.
15
+ - Search first, then read the most relevant pages with get_page.
16
+ - If the documentation does not contain the answer, say exactly what information is missing instead of inventing one.
17
+
18
+ Question: ${question.question}
19
+
20
+ Reply with a concise answer containing the specific facts the documentation provides. Plain text only.`;
21
+
22
+ /** The judge's instructions: grade the answer against the expected facts. */
23
+ export const judgePrompt = (question: EvalQuestion, answer: string): string => {
24
+ const facts = question.expected.map((fact) => `- ${fact}`).join("\n");
25
+ return `You are grading an answer against expected facts. Do not use any tools.
26
+
27
+ Question: ${question.question}
28
+
29
+ Expected facts — each must be present in substance (paraphrase is fine, contradiction is not):
30
+ ${facts}
31
+
32
+ Answer to grade:
33
+ """
34
+ ${answer}
35
+ """
36
+
37
+ An answer that states the documentation lacks the information FAILS.
38
+
39
+ Reply with ONLY this JSON object on a single line, no markdown fences:
40
+ {"pass": true|false, "score": 0.0-1.0, "missing": ["expected facts absent or contradicted"], "notes": "one sentence"}`;
41
+ };
42
+
43
+ /** The `--fix` handoff prompt: where the report is and the ground rules. */
44
+ export const evalFixPrompt = (reportPath: string): string =>
45
+ `Fix the documentation gaps found by \`blume eval\` in this project.
46
+
47
+ The full report is at ${reportPath}. It is JSON: each entry in \`eval.results\` with status "fail" is one question the documentation could not answer. Each carries the \`question\`, the \`expected\` facts, the judge's \`missing\` facts, and the reader agent's \`answer\` (what the docs currently convey). The matching \`diagnostics\` entry names the source \`file\` of the page that should answer it.
48
+
49
+ Work through every failed question:
50
+ 1. Read the page named in the finding (or choose the best page when none is named).
51
+ 2. Edit the documentation so it states the missing facts explicitly. Add prose, not filler; keep the page's voice.
52
+ 3. Never delete questions from the evals file or weaken expected facts.
53
+
54
+ When you are done, run \`blume eval\` to verify, and repeat until every question passes.`;
55
+
56
+ /** The `eval init` prompt: draft a starter evals file from the docs. */
57
+ export const initPrompt = (evalsPath: string): string =>
58
+ `Draft a starter evals file for \`blume eval\` in this documentation project.
59
+
60
+ Read the documentation source pages in this project and write ${evalsPath} with about 10 high-value questions a real user would ask — installation, configuration, deployment, and the project's headline features. For each question, list the expected facts a correct answer must state, grounded in what the documentation actually promises (never invent facts the docs don't state).
61
+
62
+ The file format is YAML:
63
+
64
+ questions:
65
+ - id: kebab-case-slug
66
+ question: One user question?
67
+ expected:
68
+ - a fact the answer must contain
69
+ - another required fact
70
+ routes:
71
+ - /route/of/the/page/that/answers/it
72
+
73
+ Rules:
74
+ - Every \`expected\` fact must be verifiable in the docs today.
75
+ - Prefer questions whose answers live on one page; set \`routes\` to that page.
76
+ - Keep ids unique and questions short.
77
+
78
+ When you are done, print the file and suggest running \`blume eval\` to try it.`;
@@ -0,0 +1,214 @@
1
+ import { mkdtemp, writeFile } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+
4
+ import { join, relative } from "pathe";
5
+
6
+ import { AGENTS } from "../audit/agent.ts";
7
+ import { countBySeverity } from "../core/diagnostics.ts";
8
+ import type { EvalResult, QuestionResult, QuestionStatus } from "./run.ts";
9
+
10
+ const ESC = String.fromCodePoint(27);
11
+ const COLORS = {
12
+ bold: `${ESC}[1m`,
13
+ cyan: `${ESC}[36m`,
14
+ dim: `${ESC}[2m`,
15
+ green: `${ESC}[32m`,
16
+ red: `${ESC}[31m`,
17
+ reset: `${ESC}[0m`,
18
+ yellow: `${ESC}[33m`,
19
+ };
20
+
21
+ const GLYPH: Record<QuestionStatus, string> = {
22
+ error: "!",
23
+ fail: "✖",
24
+ pass: "✔",
25
+ skip: "⊘",
26
+ };
27
+
28
+ const STATUS_COLOR: Record<QuestionStatus, string> = {
29
+ error: COLORS.yellow,
30
+ fail: COLORS.red,
31
+ pass: COLORS.green,
32
+ skip: COLORS.dim,
33
+ };
34
+
35
+ /** Longest id gets the room; everything shorter aligns to it. */
36
+ const ID_PAD = 28;
37
+
38
+ const seconds = (ms: number): string => `${(ms / 1000).toFixed(1)}s`;
39
+
40
+ const money = (cost: number | undefined): string =>
41
+ cost === undefined ? "" : `$${cost.toFixed(2)}`;
42
+
43
+ const duration = (ms: number): string => {
44
+ if (ms < 60_000) {
45
+ return seconds(ms);
46
+ }
47
+ const minutes = Math.floor(ms / 60_000);
48
+ const rest = Math.round((ms % 60_000) / 1000);
49
+ return `${minutes}m ${rest}s`;
50
+ };
51
+
52
+ /** One question's progress/report line: glyph, id, status, score, time, cost. */
53
+ export const questionLine = (result: QuestionResult): string => {
54
+ const color = STATUS_COLOR[result.status];
55
+ const glyph = `${color}${GLYPH[result.status]}${COLORS.reset}`;
56
+ const id = result.id.padEnd(ID_PAD);
57
+ if (result.status === "skip") {
58
+ return ` ${glyph} ${id} ${COLORS.dim}skipped${COLORS.reset}`;
59
+ }
60
+ const score = result.score === undefined ? "" : result.score.toFixed(2);
61
+ const cells = [
62
+ `${color}${result.status}${COLORS.reset}`,
63
+ score,
64
+ `${COLORS.dim}${seconds(result.durationMs)}${COLORS.reset}`,
65
+ `${COLORS.dim}${money(result.costUsd)}${COLORS.reset}`,
66
+ ]
67
+ .filter((cell) => cell !== "")
68
+ .join(" ");
69
+ return ` ${glyph} ${id} ${cells}`;
70
+ };
71
+
72
+ /** Indented context under a question's line: missing facts, error detail. */
73
+ export const questionDetails = (
74
+ result: QuestionResult,
75
+ verbose: boolean
76
+ ): string[] => {
77
+ const lines: string[] = [];
78
+ if (result.status === "fail") {
79
+ for (const fact of result.missing) {
80
+ lines.push(` ${COLORS.dim}missing: ${fact}${COLORS.reset}`);
81
+ }
82
+ }
83
+ if (result.status === "error" && result.detail) {
84
+ lines.push(` ${COLORS.dim}${result.detail}${COLORS.reset}`);
85
+ }
86
+ if (verbose && result.answer && result.status !== "pass") {
87
+ lines.push(
88
+ ...result.answer
89
+ .split("\n")
90
+ .map((line) => ` ${COLORS.dim}> ${line}${COLORS.reset}`)
91
+ );
92
+ }
93
+ return lines;
94
+ };
95
+
96
+ /** The one-line totals: `9 passed · 2 failed · 1 skipped · 1m 42s · $0.71`. */
97
+ export const summaryLine = (result: EvalResult): string => {
98
+ const { counts } = result;
99
+ const parts = [
100
+ `${counts.pass} passed`,
101
+ counts.fail > 0 ? `${counts.fail} failed` : "",
102
+ counts.error > 0 ? `${counts.error} errored` : "",
103
+ counts.skip > 0 ? `${counts.skip} skipped` : "",
104
+ duration(result.durationMs),
105
+ money(result.costUsd),
106
+ ].filter((part) => part !== "");
107
+ return parts.join(" · ");
108
+ };
109
+
110
+ /** The header line the command prints before the first question runs. */
111
+ export const headerLine = (total: number, agent: EvalResult["agent"]): string =>
112
+ `${COLORS.bold}blume eval${COLORS.reset} ${total} question(s) · ${AGENTS[agent].name}`;
113
+
114
+ /** The dim announce line while a question's agents run. */
115
+ export const startLine = (id: string, index: number, total: number): string =>
116
+ ` ${COLORS.dim}▸ ${id} (${index + 1}/${total})${COLORS.reset}`;
117
+
118
+ /** `fix:` pointers for failed questions, naming the file that resolves each. */
119
+ export const fixLines = (result: EvalResult, root: string): string[] =>
120
+ result.diagnostics
121
+ .filter((diagnostic) => diagnostic.code !== "BLUME_EVAL_ROUTE_UNKNOWN")
122
+ .map((finding) => {
123
+ const site = finding.file
124
+ ? `${relative(root, finding.file)}${finding.line ? `:${finding.line}` : ""}`
125
+ : "";
126
+ return ` ${COLORS.cyan}fix:${COLORS.reset} ${site} ${COLORS.dim}${finding.message}${COLORS.reset}`;
127
+ });
128
+
129
+ /** Dim warnings for route hints that no longer match a page. */
130
+ export const warningLines = (result: EvalResult, root: string): string[] =>
131
+ result.diagnostics
132
+ .filter((diagnostic) => diagnostic.code === "BLUME_EVAL_ROUTE_UNKNOWN")
133
+ .map((finding) => {
134
+ const site = finding.file
135
+ ? ` ${relative(root, finding.file)}${finding.line ? `:${finding.line}` : ""}`
136
+ : "";
137
+ return ` ${COLORS.yellow}⚠${COLORS.reset}${site} ${COLORS.dim}${finding.message}${COLORS.reset}`;
138
+ });
139
+
140
+ /** The human report, written to stderr by the command. */
141
+ export const formatEvalReport = (
142
+ result: EvalResult,
143
+ root: string,
144
+ options: { verbose?: boolean } = {}
145
+ ): string => {
146
+ const lines: string[] = [headerLine(result.results.length, result.agent), ""];
147
+
148
+ for (const question of result.results) {
149
+ lines.push(
150
+ questionLine(question),
151
+ ...questionDetails(question, Boolean(options.verbose))
152
+ );
153
+ }
154
+ lines.push("");
155
+
156
+ // The finding tells the author which file fixes which failure.
157
+ const failures = fixLines(result, root);
158
+ lines.push(...failures);
159
+ if (failures.length > 0) {
160
+ lines.push("");
161
+ }
162
+
163
+ lines.push(` ${summaryLine(result)}`, "");
164
+ return lines.join("\n");
165
+ };
166
+
167
+ /**
168
+ * The machine-readable report. The `diagnostics` + `summary` shape matches
169
+ * `blume validate --json` and `blume audit --json` exactly — anything parsing
170
+ * those keeps working — with the eval run's own results alongside.
171
+ */
172
+ export const evalReportJson = (
173
+ result: EvalResult,
174
+ root: string,
175
+ threshold: number
176
+ ): string => {
177
+ const diagnostics = result.diagnostics.map((diagnostic) =>
178
+ diagnostic.file
179
+ ? { ...diagnostic, file: relative(root, diagnostic.file) }
180
+ : diagnostic
181
+ );
182
+ return `${JSON.stringify(
183
+ {
184
+ diagnostics,
185
+ eval: {
186
+ agent: result.agent,
187
+ costUsd: result.costUsd,
188
+ counts: result.counts,
189
+ durationMs: result.durationMs,
190
+ results: result.results,
191
+ threshold,
192
+ },
193
+ summary: countBySeverity(result.diagnostics),
194
+ },
195
+ null,
196
+ 2
197
+ )}\n`;
198
+ };
199
+
200
+ /**
201
+ * Write the full JSON report where a `--fix` agent can read it — a file
202
+ * rather than inline prompt text, because a long run's answers would exceed
203
+ * the platform's argv limit.
204
+ */
205
+ export const writeEvalReport = async (
206
+ result: EvalResult,
207
+ root: string,
208
+ threshold: number
209
+ ): Promise<string> => {
210
+ const dir = await mkdtemp(join(tmpdir(), "blume-eval-"));
211
+ const path = join(dir, "report.json");
212
+ await writeFile(path, evalReportJson(result, root, threshold));
213
+ return path;
214
+ };
@@ -0,0 +1,290 @@
1
+ import { mkdir, mkdtemp, writeFile } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+
4
+ import { join } from "pathe";
5
+
6
+ import { buildMcpData } from "../ai/mcp/data.ts";
7
+ import { AGENTS } from "../audit/agent.ts";
8
+ import type { AgentKind } from "../audit/agent.ts";
9
+ import type { BlumeProject } from "../core/project-graph.ts";
10
+ import type { Diagnostic } from "../core/types.ts";
11
+ import {
12
+ agentArgs,
13
+ parseVerdict,
14
+ readAgentOutput,
15
+ runAgentHeadless,
16
+ writeMcpConfig,
17
+ } from "./agents.ts";
18
+ import type { HeadlessRunner } from "./agents.ts";
19
+ import { questionFinding, routeFindings } from "./findings.ts";
20
+ import { judgePrompt, readerPrompt } from "./prompts.ts";
21
+ import type { EvalQuestion, EvalsFile } from "./schema.ts";
22
+
23
+ /** Reader runs search and read several pages; the judge grades one answer. */
24
+ const DEFAULT_READER_TIMEOUT_MS = 180_000;
25
+ const DEFAULT_JUDGE_TIMEOUT_MS = 60_000;
26
+
27
+ export type QuestionStatus = "error" | "fail" | "pass" | "skip";
28
+
29
+ /** One question's outcome, carrying everything the reports print. */
30
+ export interface QuestionResult {
31
+ answer?: string;
32
+ costUsd?: number;
33
+ /** Why the run errored (timeout, agent failure, unparseable verdict). */
34
+ detail?: string;
35
+ durationMs: number;
36
+ expected: string[];
37
+ id: string;
38
+ missing: string[];
39
+ notes?: string;
40
+ question: string;
41
+ routes: string[];
42
+ score?: number;
43
+ status: QuestionStatus;
44
+ }
45
+
46
+ export interface EvalResult {
47
+ agent: AgentKind;
48
+ /** Total spend, when the agent CLI reports it (claude does, codex doesn't). */
49
+ costUsd?: number;
50
+ counts: Record<QuestionStatus, number>;
51
+ diagnostics: Diagnostic[];
52
+ durationMs: number;
53
+ results: QuestionResult[];
54
+ }
55
+
56
+ export type EvalProgress =
57
+ | {
58
+ kind: "question-end";
59
+ index: number;
60
+ result: QuestionResult;
61
+ total: number;
62
+ }
63
+ | { kind: "question-start"; id: string; index: number; total: number };
64
+
65
+ export interface EvalRunOptions {
66
+ agent: AgentKind;
67
+ evals: EvalsFile;
68
+ /** Where the evals file lives, for findings with no usable route hint. */
69
+ evalsPath: string;
70
+ judgeTimeoutMs?: number;
71
+ onProgress?: (event: EvalProgress) => void;
72
+ project: BlumeProject;
73
+ /** The evals file's raw text, for line-anchoring findings. */
74
+ rawEvals: string;
75
+ readerTimeoutMs?: number;
76
+ /** The spawn function — injectable so tests never launch a real agent. */
77
+ run?: HeadlessRunner;
78
+ }
79
+
80
+ interface QuestionContext {
81
+ bin: string;
82
+ dir: string;
83
+ judgeTimeoutMs: number;
84
+ kind: AgentKind;
85
+ mcp: Awaited<ReturnType<typeof writeMcpConfig>>;
86
+ readerTimeoutMs: number;
87
+ run: HeadlessRunner;
88
+ }
89
+
90
+ const errored = (
91
+ question: EvalQuestion,
92
+ detail: string,
93
+ durationMs: number
94
+ ): QuestionResult => ({
95
+ detail,
96
+ durationMs,
97
+ expected: question.expected,
98
+ id: question.id,
99
+ missing: [],
100
+ question: question.question,
101
+ routes: question.routes,
102
+ status: "error",
103
+ });
104
+
105
+ /** Run one question: a fresh empty cwd, the reader, then the judge. */
106
+ const runQuestion = async (
107
+ question: EvalQuestion,
108
+ index: number,
109
+ context: QuestionContext
110
+ ): Promise<QuestionResult> => {
111
+ const started = performance.now();
112
+ const elapsed = () => Math.round(performance.now() - started);
113
+
114
+ // An empty working directory per invocation is the fresh-eyes guardrail:
115
+ // even if a tool restriction slips, there is nothing here to read.
116
+ const workDir = join(context.dir, `work-${index}`);
117
+ await mkdir(workDir, { recursive: true });
118
+
119
+ const answerPath = join(workDir, "answer.txt");
120
+ const reader = await context.run(
121
+ context.bin,
122
+ agentArgs(context.kind, { lastMessagePath: answerPath, mcp: context.mcp }),
123
+ {
124
+ cwd: workDir,
125
+ prompt: readerPrompt(question),
126
+ timeoutMs: context.readerTimeoutMs,
127
+ }
128
+ );
129
+ const answer = await readAgentOutput(context.kind, reader, answerPath);
130
+ if (answer.isError) {
131
+ return {
132
+ ...errored(question, `reader ${answer.detail ?? "failed"}`, elapsed()),
133
+ costUsd: answer.costUsd,
134
+ };
135
+ }
136
+
137
+ const verdictPath = join(workDir, "verdict.txt");
138
+ const judge = await context.run(
139
+ context.bin,
140
+ agentArgs(context.kind, { lastMessagePath: verdictPath }),
141
+ {
142
+ cwd: workDir,
143
+ prompt: judgePrompt(question, answer.text),
144
+ timeoutMs: context.judgeTimeoutMs,
145
+ }
146
+ );
147
+ const graded = await readAgentOutput(context.kind, judge, verdictPath);
148
+ const costUsd =
149
+ answer.costUsd === undefined && graded.costUsd === undefined
150
+ ? undefined
151
+ : (answer.costUsd ?? 0) + (graded.costUsd ?? 0);
152
+ if (graded.isError) {
153
+ return {
154
+ ...errored(question, `judge ${graded.detail ?? "failed"}`, elapsed()),
155
+ answer: answer.text,
156
+ costUsd,
157
+ };
158
+ }
159
+
160
+ const verdict = parseVerdict(graded.text);
161
+ if (!verdict) {
162
+ return {
163
+ ...errored(question, "judge returned no parseable verdict", elapsed()),
164
+ answer: answer.text,
165
+ costUsd,
166
+ };
167
+ }
168
+
169
+ return {
170
+ answer: answer.text,
171
+ costUsd,
172
+ durationMs: elapsed(),
173
+ expected: question.expected,
174
+ id: question.id,
175
+ missing: verdict.missing,
176
+ notes: verdict.notes || undefined,
177
+ question: question.question,
178
+ routes: question.routes,
179
+ score: verdict.score,
180
+ status: verdict.pass ? "pass" : "fail",
181
+ };
182
+ };
183
+
184
+ /**
185
+ * Run every question through the reader/judge pair, sequentially: each
186
+ * question is already two agent sessions, and a serial run keeps progress
187
+ * output ordered and cost attribution obvious.
188
+ */
189
+ export const runEval = async (options: EvalRunOptions): Promise<EvalResult> => {
190
+ const started = performance.now();
191
+ const kind = options.agent;
192
+ const run = options.run ?? runAgentHeadless;
193
+ const anchor = { path: options.evalsPath, raw: options.rawEvals };
194
+
195
+ const dir = await mkdtemp(join(tmpdir(), "blume-eval-"));
196
+ const snapshotPath = join(dir, "mcp-data.json");
197
+ await writeFile(
198
+ snapshotPath,
199
+ JSON.stringify(await buildMcpData(options.project))
200
+ );
201
+ const mcp = await writeMcpConfig(dir, snapshotPath);
202
+
203
+ const context: QuestionContext = {
204
+ bin: AGENTS[kind].bin,
205
+ dir,
206
+ judgeTimeoutMs: options.judgeTimeoutMs ?? DEFAULT_JUDGE_TIMEOUT_MS,
207
+ kind,
208
+ mcp,
209
+ readerTimeoutMs: options.readerTimeoutMs ?? DEFAULT_READER_TIMEOUT_MS,
210
+ run,
211
+ };
212
+
213
+ const diagnostics: Diagnostic[] = [];
214
+ const results: QuestionResult[] = [];
215
+ const { questions } = options.evals;
216
+
217
+ for (const [index, question] of questions.entries()) {
218
+ diagnostics.push(...routeFindings(question, options.project, anchor));
219
+
220
+ if (question.skip) {
221
+ results.push({
222
+ durationMs: 0,
223
+ expected: question.expected,
224
+ id: question.id,
225
+ missing: [],
226
+ question: question.question,
227
+ routes: question.routes,
228
+ status: "skip",
229
+ });
230
+ continue;
231
+ }
232
+
233
+ options.onProgress?.({
234
+ id: question.id,
235
+ index,
236
+ kind: "question-start",
237
+ total: questions.length,
238
+ });
239
+ // Sequential by design: each question is already two agent sessions, and
240
+ // a serial run keeps progress output ordered and cost attribution obvious.
241
+ // oxlint-disable-next-line no-await-in-loop
242
+ const result = await runQuestion(question, index, context);
243
+ results.push(result);
244
+ if (result.status === "fail" || result.status === "error") {
245
+ diagnostics.push(
246
+ questionFinding(
247
+ question,
248
+ {
249
+ detail: result.detail,
250
+ missing: result.missing,
251
+ status: result.status,
252
+ },
253
+ options.project,
254
+ anchor
255
+ )
256
+ );
257
+ }
258
+ options.onProgress?.({
259
+ index,
260
+ kind: "question-end",
261
+ result,
262
+ total: questions.length,
263
+ });
264
+ }
265
+
266
+ const counts: Record<QuestionStatus, number> = {
267
+ error: 0,
268
+ fail: 0,
269
+ pass: 0,
270
+ skip: 0,
271
+ };
272
+ for (const result of results) {
273
+ counts[result.status] += 1;
274
+ }
275
+ const costs = results.flatMap((result) =>
276
+ result.costUsd === undefined ? [] : [result.costUsd]
277
+ );
278
+
279
+ return {
280
+ agent: kind,
281
+ costUsd:
282
+ costs.length > 0
283
+ ? costs.reduce((total, cost) => total + cost, 0)
284
+ : undefined,
285
+ counts,
286
+ diagnostics,
287
+ durationMs: Math.round(performance.now() - started),
288
+ results,
289
+ };
290
+ };