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
@@ -1,3 +1,4 @@
1
+ import type { AstroIntegration } from "astro";
1
2
  import { z } from "zod";
2
3
 
3
4
  import type { ComponentMarkdown } from "../ai/component-markdown.ts";
@@ -78,6 +79,11 @@ const searchMetaSchema = z.strictObject({
78
79
  tags: z.array(z.string()).optional(),
79
80
  });
80
81
 
82
+ const aiMetaSchema = z.strictObject({
83
+ /** Exclude this page from llms.txt and llms-full.txt. */
84
+ exclude: z.boolean().default(false),
85
+ });
86
+
81
87
  const changelogMetaSchema = z.strictObject({
82
88
  category: z.string().optional(),
83
89
  date: dateSchema.optional(),
@@ -105,6 +111,7 @@ const authorSchema = z.union([
105
111
 
106
112
  /** Frontmatter accepted on any content page. */
107
113
  const pageMetaBaseSchema = z.strictObject({
114
+ ai: aiMetaSchema.default({}),
108
115
  /** Post author(s) for blog/changelog content; preserved, not yet rendered. */
109
116
  authors: z.union([authorSchema, z.array(authorSchema)]).optional(),
110
117
  changelog: changelogMetaSchema.optional(),
@@ -351,6 +358,10 @@ const contentConfigSchema = z.strictObject({
351
358
  });
352
359
 
353
360
  const navTabSchema = z.strictObject({
361
+ // Rejected empty rather than accepted: an empty `href` would render a link to
362
+ // nowhere, and it can't mean "resolve it for me" either — that's what
363
+ // omitting the field does.
364
+ href: z.string().min(1).optional(),
354
365
  icon: iconName.optional(),
355
366
  items: z
356
367
  .array(
@@ -575,18 +586,44 @@ const mcpConfigSchema = z.strictObject({
575
586
  route: z.string().default("/mcp").transform(normalizeRoute),
576
587
  });
577
588
 
589
+ const askEndpointSchema = z
590
+ .string()
591
+ .trim()
592
+ .min(1)
593
+ .refine(
594
+ (value) => {
595
+ if (value.startsWith("/") && !value.startsWith("//")) {
596
+ return true;
597
+ }
598
+ try {
599
+ const url = new URL(value);
600
+ return url.protocol === "http:" || url.protocol === "https:";
601
+ } catch {
602
+ return false;
603
+ }
604
+ },
605
+ {
606
+ message:
607
+ "ai.ask.endpoint must be an HTTP(S) URL or a root-relative path.",
608
+ }
609
+ );
610
+
578
611
  const aiConfigSchema = z.strictObject({
579
612
  ask: z
580
613
  .strictObject({
581
614
  // Name of the env var holding the provider's API key; each provider has
582
615
  // a sensible default, so this only needs setting to override it.
583
616
  apiKeyEnv: z.string().optional(),
584
- // Base URL of the backend. Required for `openai-compatible`; for the
585
- // named providers it overrides the built-in preset.
617
+ // Base URL of the backend. Required for `openai-compatible` only when no
618
+ // external endpoint is supplied; for named providers it overrides the preset.
586
619
  // blume bundles Zod 3; top-level `z.url()` is undefined at runtime.
587
620
  // oxlint-disable-next-line react-doctor/zod-v4-prefer-top-level-string-formats
588
621
  baseUrl: z.string().url().optional(),
589
622
  enabled: z.boolean().default(false),
623
+ // Optional external endpoint for projects that keep their docs static
624
+ // and host Ask AI in an existing backend. Absolute URLs and root-relative
625
+ // paths are both valid; the built-in request/stream contract is unchanged.
626
+ endpoint: askEndpointSchema.optional(),
590
627
  model: z.string().default("openai/gpt-5.5"),
591
628
  provider: z.enum(askAiProviders).default("gateway"),
592
629
  // Empty-state prompts shown before the first question. Each renders as a
@@ -603,7 +640,10 @@ const aiConfigSchema = z.strictObject({
603
640
  .superRefine((value, ctx) => {
604
641
  // A generic OpenAI-compatible backend has no preset URL, so the user
605
642
  // must supply one; the named providers fall back to their preset.
606
- if (value.provider === "openai-compatible" && !value.baseUrl) {
643
+ if (
644
+ value.provider === "openai-compatible" &&
645
+ !(value.baseUrl || value.endpoint)
646
+ ) {
607
647
  ctx.addIssue({
608
648
  code: z.ZodIssueCode.custom,
609
649
  message:
@@ -1118,15 +1158,21 @@ const reactConfigSchema = z.strictObject({
1118
1158
  * `http(s)` URL (OpenAPI for the Blume renderer; OpenAPI or AsyncAPI for Scalar).
1119
1159
  */
1120
1160
  const openapiSourceSchema = z.strictObject({
1161
+ /** Include generated pages from this spec in llms.txt/llms-full.txt. */
1162
+ includeInLlms: z.boolean().default(true),
1163
+ /** Include generated pages from this spec in site search. */
1164
+ includeInSearch: z.boolean().default(true),
1121
1165
  /** Nav/section label for this source. */
1122
1166
  label: z.string().optional(),
1167
+ /** Emit noindex metadata and omit generated pages from the sitemap. */
1168
+ noindex: z.boolean().default(false),
1123
1169
  /** Per-source route; defaults to the block's `route` (or a derived path). */
1124
1170
  route: z.string().optional(),
1125
1171
  /** Local path or `http(s)` URL to the spec. */
1126
1172
  spec: z.string(),
1127
1173
  });
1128
1174
 
1129
- export type OpenApiSource = z.infer<typeof openapiSourceSchema>;
1175
+ export type OpenApiSource = z.input<typeof openapiSourceSchema>;
1130
1176
 
1131
1177
  /**
1132
1178
  * Arbitrary Scalar API-reference options forwarded verbatim to the generated
@@ -1283,6 +1329,7 @@ export const blumeConfigSchema = z.strictObject({
1283
1329
  frontmatter: frontmatterConfigSchema.default({}),
1284
1330
  github: githubConfigSchema.optional(),
1285
1331
  i18n: i18nConfigSchema.optional(),
1332
+ integrations: z.array(z.custom<AstroIntegration>()).default([]),
1286
1333
  lastModified: lastModifiedConfigSchema.default(false),
1287
1334
  logo: logoConfigSchema.optional(),
1288
1335
  markdown: markdownConfigSchema.default({}),
@@ -7,7 +7,7 @@ import type { ResolvedConfig } from "./schema.ts";
7
7
  */
8
8
  export const serverFeatures = (config: ResolvedConfig): string[] => {
9
9
  const features: string[] = [];
10
- if (config.ai.ask?.enabled) {
10
+ if (config.ai.ask?.enabled && !config.ai.ask.endpoint) {
11
11
  features.push("Ask AI");
12
12
  }
13
13
  // The hosted MCP server is a live JSON-RPC endpoint, so it needs a runtime.
@@ -38,6 +38,15 @@ export const slugify = (text: string): string =>
38
38
  .replaceAll(/-+/gu, "-")
39
39
  .replaceAll(/^-|-$/gu, "");
40
40
 
41
+ /**
42
+ * {@link slugify} for a slug that may span path segments (`guides/setup`).
43
+ * `slugify` deletes `/` along with all other punctuation, which would mash
44
+ * `guides/setup` into `guidessetup` — and collide it with a genuine `guidessetup`
45
+ * document. Each segment is slugged on its own and the separators kept.
46
+ */
47
+ export const slugifyPath = (text: string): string =>
48
+ text.split("/").map(slugify).filter(Boolean).join("/");
49
+
41
50
  /** Title-case a slug segment for display. */
42
51
  const titleCase = (value: string): string =>
43
52
  value
@@ -104,13 +113,24 @@ type FenceState = "```" | "~~~" | null;
104
113
  * the state untouched.
105
114
  */
106
115
  const nextFenceState = (line: string, fence: FenceState): FenceState => {
107
- const delimiter = line.trimStart().match(CODE_FENCE)?.groups?.delimiter as
116
+ const trimmed = line.trimStart();
117
+ const delimiter = trimmed.match(CODE_FENCE)?.groups?.delimiter as
108
118
  | Exclude<FenceState, null>
109
119
  | undefined;
110
120
  if (delimiter === undefined) {
111
121
  return fence;
112
122
  }
113
123
  if (fence === null) {
124
+ // A backtick fence's info string cannot itself contain a backtick
125
+ // (CommonMark) — a line-leading ```inline``` span is a paragraph, and
126
+ // opening a phantom fence on it would swallow every heading and link
127
+ // after it. Tilde fences carry no such rule.
128
+ if (delimiter === "```") {
129
+ const run = trimmed.match(/^`+/u)?.[0].length ?? 0;
130
+ if (trimmed.slice(run).includes("`")) {
131
+ return fence;
132
+ }
133
+ }
114
134
  return delimiter;
115
135
  }
116
136
  return fence === delimiter ? null : fence;
@@ -159,6 +179,13 @@ const linesWithoutFrontMatter = (body: string): string[] => {
159
179
  if (!/^-{3}\s*$/u.test(lines[0] ?? "")) {
160
180
  return lines;
161
181
  }
182
+ // A blank line directly after the dashes means the body *opens* with a
183
+ // thematic break, not front matter — YAML metadata starts on the very next
184
+ // line. Treating it as an unclosed block ate everything up to the next
185
+ // `---`/`...` line of an already-stripped body.
186
+ if ((lines[1] ?? "").trim() === "") {
187
+ return lines;
188
+ }
162
189
  const close = lines.findIndex(
163
190
  (line, index) => index > 0 && FRONT_MATTER_CLOSE.test(line)
164
191
  );
@@ -304,9 +331,26 @@ export const extractHeadings = (body: string): Heading[] => {
304
331
  return headings;
305
332
  };
306
333
 
307
- const MD_LINK = /\[[^\]]*\]\((?<target>[^)\s]+)(?:\s+"[^"]*")?\)/gu;
334
+ // The label admits one level of nested brackets so an image-wrapped link
335
+ // (`[![alt](/img.png)](/target)`) matches as the *outer* link — with a flat
336
+ // `[^\]]*` label the match stopped at the image's `]` and the outer target was
337
+ // never seen. The target admits one level of balanced parens so a Wikipedia-
338
+ // style URL (`/wiki/Foo_(bar)`) isn't truncated at its first `)`.
339
+ const MD_LINK =
340
+ /\[(?<label>(?:[^[\]]|\[[^\]]*\])*)\]\((?<target>(?:[^()\s]|\([^()\s]*\))+)(?<title>\s+"[^"]*")?\)/gu;
341
+ // An image inside a link label; its target was matched (and so validated) as a
342
+ // link of its own before labels admitted nesting, and still should be.
343
+ const MD_IMAGE =
344
+ /!\[[^\]]*\]\((?<target>(?:[^()\s]|\([^()\s]*\))+)(?<title>\s+"[^"]*")?\)/gu;
308
345
  const INLINE_CODE = /`[^`]*`/gu;
309
346
 
347
+ /** Column (0-based, within `matched`) where a link/image match's target starts. */
348
+ const targetOffsetIn = (
349
+ matched: string,
350
+ target: string,
351
+ title: string | undefined
352
+ ): number => matched.length - 1 - (title?.length ?? 0) - target.length;
353
+
310
354
  /**
311
355
  * Extract link targets from a markdown body for later validation, recording the
312
356
  * 1-based line/column of each target. Skips fenced code blocks and inline code.
@@ -335,16 +379,33 @@ const scanLinkLine = (
335
379
  if (target === undefined || match.index === undefined) {
336
380
  continue;
337
381
  }
338
- // Locate the target from the `](` boundary rather than searching for the
339
- // target text from the match start otherwise a label that contains the
340
- // same text (e.g. `[/a/b](/a/b)`) reports the column inside the label. The
341
- // label can't contain `]`, so `](` is unambiguous.
342
- const targetOffset = match.index + match[0].indexOf("](") + "](".length;
382
+ // Locate the target by arithmetic from the match end rather than searching
383
+ // for its text — a label that contains the same text (e.g. `[/a/b](/a/b)`)
384
+ // would otherwise report the column inside the label.
385
+ const targetOffset = targetOffsetIn(match[0], target, match.groups?.title);
343
386
  links.push({
344
- column: targetOffset + 1,
387
+ column: match.index + targetOffset + 1,
345
388
  line: lineNumber,
346
389
  target,
347
390
  });
391
+ // An image nested in the label (`[![alt](/img.png)](/target)`) carries its
392
+ // own target; surface it too so a missing image is still caught.
393
+ const label = match[0].slice(0, targetOffset - "](".length);
394
+ for (const image of label.matchAll(MD_IMAGE)) {
395
+ const imageTarget = image.groups?.target;
396
+ if (imageTarget === undefined || image.index === undefined) {
397
+ continue;
398
+ }
399
+ links.push({
400
+ column:
401
+ match.index +
402
+ image.index +
403
+ targetOffsetIn(image[0], imageTarget, image.groups?.title) +
404
+ 1,
405
+ line: lineNumber,
406
+ target: imageTarget,
407
+ });
408
+ }
348
409
  }
349
410
  return next;
350
411
  };
@@ -12,7 +12,7 @@ import {
12
12
  pollingWatch,
13
13
  snapshotCache,
14
14
  } from "./cache.ts";
15
- import { slugify } from "./normalize.ts";
15
+ import { slugifyPath } from "./normalize.ts";
16
16
  import type {
17
17
  ContentSource,
18
18
  SourceContext,
@@ -432,7 +432,9 @@ export const notionSource = (
432
432
  const slugProp = richToMarkdown(
433
433
  page.properties[props.slug ?? "Slug"]?.rich_text
434
434
  );
435
- const slug = slugify(slugProp || title) || page.id;
435
+ // Path-aware: a `guides/setup` slug keeps its `/` (per-segment slugging)
436
+ // instead of mashing into `guidessetup`.
437
+ const slug = slugifyPath(slugProp || title) || page.id;
436
438
  return { data, slug };
437
439
  };
438
440
 
@@ -8,7 +8,7 @@ import {
8
8
  pollingWatch,
9
9
  snapshotCache,
10
10
  } from "./cache.ts";
11
- import { slugify } from "./normalize.ts";
11
+ import { slugify, slugifyPath } from "./normalize.ts";
12
12
  import { portableTextToMarkdown } from "./portable-text.ts";
13
13
  import type { PortableTextBlock } from "./portable-text.ts";
14
14
  import type {
@@ -142,9 +142,11 @@ export const sanitySource = (
142
142
  "untitled";
143
143
  // Fall back to the unique `_id` when a slug (e.g. a non-ASCII `slug.current`)
144
144
  // slugifies to empty, so distinct documents don't all collapse to the same
145
- // `untitled.md` ref and silently overwrite each other.
145
+ // `untitled.md` ref and silently overwrite each other. Path-aware: a
146
+ // `guides/setup` slug keeps its `/` (per-segment slugging) instead of
147
+ // mashing into `guidessetup`.
146
148
  const slug =
147
- slugify(slugValue) || slugify(asString(doc._id) ?? "") || "untitled";
149
+ slugifyPath(slugValue) || slugify(asString(doc._id) ?? "") || "untitled";
148
150
 
149
151
  const data: Record<string, unknown> = {};
150
152
  const title = asString(getPath(doc, fields.title ?? "title"));
package/src/core/types.ts CHANGED
@@ -201,9 +201,11 @@ export interface NavTab {
201
201
  */
202
202
  path: string;
203
203
  /**
204
- * The clickable target. Equals `path` when the section has an index page;
205
- * otherwise it's resolved to the section's first page so the tab never links
206
- * to a 404. Absent when it matches `path`.
204
+ * The clickable target. Author-declared when the config sets it; otherwise
205
+ * equals `path` when the section has an index page, and resolves to the
206
+ * section's first page when it doesn't, so the tab doesn't link to a 404 as
207
+ * long as the section has a page to offer — a section with no linkable page at
208
+ * all keeps `path`. Absent when a resolved target matches `path`.
207
209
  */
208
210
  href?: string;
209
211
  icon?: string;
@@ -0,0 +1,340 @@
1
+ import { spawn } from "node:child_process";
2
+ import { readFile, writeFile } from "node:fs/promises";
3
+
4
+ import { join } from "pathe";
5
+ import { z } from "zod";
6
+
7
+ import type { AgentKind } from "../audit/agent.ts";
8
+
9
+ /** How long the SIGTERM on timeout gets to work before SIGKILL follows. */
10
+ const KILL_GRACE_MS = 5000;
11
+
12
+ /** The MCP tools a reader run may use — nothing else. */
13
+ export const MCP_TOOL_NAMES = [
14
+ "search_docs",
15
+ "get_page",
16
+ "list_pages",
17
+ "get_navigation",
18
+ ] as const;
19
+
20
+ /** The MCP server name in the generated config; tool ids derive from it. */
21
+ const MCP_SERVER_NAME = "docs";
22
+
23
+ /**
24
+ * Claude Code built-ins that would let the agent escape the docs-only
25
+ * sandbox: the reader must not read the repo, run commands, or search the
26
+ * web — it sees the documentation the way a stranger does, through MCP.
27
+ */
28
+ const DISALLOWED_TOOLS = [
29
+ "Bash",
30
+ "Read",
31
+ "Glob",
32
+ "Grep",
33
+ "Write",
34
+ "Edit",
35
+ "NotebookEdit",
36
+ "WebFetch",
37
+ "WebSearch",
38
+ "Task",
39
+ ];
40
+
41
+ /** The captured outcome of one headless agent invocation. */
42
+ export interface HeadlessResult {
43
+ code: number;
44
+ stderr: string;
45
+ stdout: string;
46
+ timedOut: boolean;
47
+ }
48
+
49
+ export interface HeadlessOptions {
50
+ cwd: string;
51
+ platform?: NodeJS.Platform;
52
+ prompt: string;
53
+ timeoutMs: number;
54
+ }
55
+
56
+ /**
57
+ * Run an agent CLI headlessly: prompt over stdin (dodging argv limits and
58
+ * cmd.exe newline quoting alike), stdout and stderr captured, SIGTERM at the
59
+ * deadline with a SIGKILL follow-up. Resolves with the captured result;
60
+ * rejects only when the executable cannot be spawned at all (ENOENT).
61
+ */
62
+ export const runAgentHeadless = (
63
+ bin: string,
64
+ args: string[],
65
+ options: HeadlessOptions
66
+ ): Promise<HeadlessResult> =>
67
+ // oxlint-disable-next-line promise/avoid-new -- adapt spawn's event callbacks
68
+ new Promise((resolve, reject) => {
69
+ const platform = options.platform ?? process.platform;
70
+ // npm installs agent CLIs as `.cmd` shims on Windows, which Node refuses
71
+ // to spawn without a shell. Arguments are plain flags and absolute paths,
72
+ // so shell interpolation has nothing to mangle.
73
+ const child = spawn(bin, args, {
74
+ cwd: options.cwd,
75
+ shell: platform === "win32",
76
+ stdio: ["pipe", "pipe", "pipe"],
77
+ });
78
+
79
+ let stdout = "";
80
+ let stderr = "";
81
+ let timedOut = false;
82
+ child.stdout.on("data", (chunk: Buffer) => {
83
+ stdout += chunk.toString("utf-8");
84
+ });
85
+ child.stderr.on("data", (chunk: Buffer) => {
86
+ stderr += chunk.toString("utf-8");
87
+ });
88
+
89
+ const deadline = setTimeout(() => {
90
+ timedOut = true;
91
+ child.kill("SIGTERM");
92
+ const hardKill = setTimeout(() => child.kill("SIGKILL"), KILL_GRACE_MS);
93
+ hardKill.unref();
94
+ }, options.timeoutMs);
95
+ deadline.unref();
96
+
97
+ child.once("error", (error) => {
98
+ clearTimeout(deadline);
99
+ reject(error);
100
+ });
101
+ child.once("close", (code) => {
102
+ clearTimeout(deadline);
103
+ resolve({ code: code ?? 1, stderr, stdout, timedOut });
104
+ });
105
+ // `close` waits for the stdio pipes, which a killed agent's own children
106
+ // (an MCP server, a shell) can hold open past the SIGTERM. A timed-out
107
+ // run's output is discarded anyway, so the process dying is enough.
108
+ child.once("exit", (code) => {
109
+ if (timedOut) {
110
+ clearTimeout(deadline);
111
+ resolve({ code: code ?? 1, stderr, stdout, timedOut });
112
+ }
113
+ });
114
+
115
+ child.stdin.end(options.prompt);
116
+ });
117
+
118
+ /** The spawn signature `runEval` accepts, injectable for tests. */
119
+ export type HeadlessRunner = typeof runAgentHeadless;
120
+
121
+ /** How the eval reaches the MCP stdio bridge from a spawned agent. */
122
+ export interface McpLaunch {
123
+ /** The generated MCP config file (claude's `--mcp-config`). */
124
+ configPath: string;
125
+ /** argv for the bridge process (codex's `-c mcp_servers` override). */
126
+ serverArgs: string[];
127
+ /** The executable launching the bridge. */
128
+ serverCommand: string;
129
+ }
130
+
131
+ /**
132
+ * Write the MCP config a reader run points its agent CLI at. The bridge is
133
+ * this same CLI relaunched (`blume mcp-stdio`), which resolves correctly from
134
+ * both a source checkout (bun + src/cli/index.ts) and an installed package
135
+ * (node + bin/blume.mjs).
136
+ */
137
+ export const writeMcpConfig = async (
138
+ dir: string,
139
+ snapshotPath: string,
140
+ launcher?: { args: string[]; command: string }
141
+ ): Promise<McpLaunch> => {
142
+ const resolved = launcher ?? {
143
+ args: [process.argv[1] ?? "", "mcp-stdio", "--data", snapshotPath],
144
+ command: process.execPath,
145
+ };
146
+ const configPath = join(dir, "mcp-config.json");
147
+ const config = {
148
+ mcpServers: {
149
+ [MCP_SERVER_NAME]: { args: resolved.args, command: resolved.command },
150
+ },
151
+ };
152
+ await writeFile(configPath, JSON.stringify(config, null, 2));
153
+ return {
154
+ configPath,
155
+ serverArgs: resolved.args,
156
+ serverCommand: resolved.command,
157
+ };
158
+ };
159
+
160
+ export interface InvocationContext {
161
+ /** Where codex writes its final message; unused by claude. */
162
+ lastMessagePath: string;
163
+ /** The MCP bridge for reader runs; omitted for the judge. */
164
+ mcp?: McpLaunch;
165
+ }
166
+
167
+ const CLAUDE_READER_MAX_TURNS = "25";
168
+ const CLAUDE_JUDGE_MAX_TURNS = "1";
169
+
170
+ const claudeArgs = (context: InvocationContext): string[] => {
171
+ const base = ["-p", "--output-format", "json", "--strict-mcp-config"];
172
+ if (context.mcp) {
173
+ const allowed = MCP_TOOL_NAMES.map(
174
+ (tool) => `mcp__${MCP_SERVER_NAME}__${tool}`
175
+ ).join(",");
176
+ return [
177
+ ...base,
178
+ "--mcp-config",
179
+ context.mcp.configPath,
180
+ "--allowedTools",
181
+ allowed,
182
+ "--disallowedTools",
183
+ DISALLOWED_TOOLS.join(","),
184
+ "--max-turns",
185
+ CLAUDE_READER_MAX_TURNS,
186
+ ];
187
+ }
188
+ return [
189
+ ...base,
190
+ "--disallowedTools",
191
+ DISALLOWED_TOOLS.join(","),
192
+ "--max-turns",
193
+ CLAUDE_JUDGE_MAX_TURNS,
194
+ ];
195
+ };
196
+
197
+ const codexArgs = (context: InvocationContext): string[] => {
198
+ const base = [
199
+ "exec",
200
+ "--skip-git-repo-check",
201
+ "--ignore-user-config",
202
+ "--ephemeral",
203
+ "--sandbox",
204
+ "read-only",
205
+ "--output-last-message",
206
+ context.lastMessagePath,
207
+ ];
208
+ if (context.mcp) {
209
+ // `-c` values parse as TOML; JSON string/array literals are valid TOML
210
+ // values, so JSON.stringify produces exactly the quoting codex expects.
211
+ return [
212
+ ...base,
213
+ "-c",
214
+ `mcp_servers.${MCP_SERVER_NAME}.command=${JSON.stringify(
215
+ context.mcp.serverCommand
216
+ )}`,
217
+ "-c",
218
+ `mcp_servers.${MCP_SERVER_NAME}.args=${JSON.stringify(
219
+ context.mcp.serverArgs
220
+ )}`,
221
+ "-",
222
+ ];
223
+ }
224
+ return [...base, "-"];
225
+ };
226
+
227
+ /** Build the argv for one headless run; pass `mcp` for the reader role. */
228
+ export const agentArgs = (
229
+ kind: AgentKind,
230
+ context: InvocationContext
231
+ ): string[] => (kind === "claude" ? claudeArgs(context) : codexArgs(context));
232
+
233
+ /** What one headless run produced, normalized across agent CLIs. */
234
+ export interface AgentOutput {
235
+ costUsd?: number;
236
+ detail?: string;
237
+ isError: boolean;
238
+ text: string;
239
+ }
240
+
241
+ const claudeResultSchema = z.object({
242
+ is_error: z.boolean().default(false),
243
+ result: z.string().default(""),
244
+ total_cost_usd: z.number().optional(),
245
+ });
246
+
247
+ const tail = (value: string, max = 300): string => {
248
+ const trimmed = value.trim();
249
+ return trimmed.length > max ? trimmed.slice(-max) : trimmed;
250
+ };
251
+
252
+ /**
253
+ * Normalize a finished run into the answer text. Claude prints one JSON
254
+ * object on stdout (`--output-format json`); codex writes the final message
255
+ * to `--output-last-message` because its stdout interleaves progress.
256
+ */
257
+ export const readAgentOutput = async (
258
+ kind: AgentKind,
259
+ result: HeadlessResult,
260
+ lastMessagePath: string
261
+ ): Promise<AgentOutput> => {
262
+ if (result.timedOut) {
263
+ return { detail: "timed out", isError: true, text: "" };
264
+ }
265
+ if (result.code !== 0) {
266
+ return {
267
+ detail: tail(result.stderr) || `exited with code ${result.code}`,
268
+ isError: true,
269
+ text: "",
270
+ };
271
+ }
272
+
273
+ if (kind === "claude") {
274
+ let parsed: unknown;
275
+ try {
276
+ parsed = JSON.parse(result.stdout);
277
+ } catch {
278
+ return {
279
+ detail: "unparseable --output-format json payload",
280
+ isError: true,
281
+ text: "",
282
+ };
283
+ }
284
+ const payload = claudeResultSchema.safeParse(parsed);
285
+ if (!payload.success) {
286
+ return {
287
+ detail: "unexpected --output-format json shape",
288
+ isError: true,
289
+ text: "",
290
+ };
291
+ }
292
+ return {
293
+ costUsd: payload.data.total_cost_usd,
294
+ isError: payload.data.is_error,
295
+ text: payload.data.result,
296
+ };
297
+ }
298
+
299
+ let text: string;
300
+ try {
301
+ const raw = await readFile(lastMessagePath, "utf-8");
302
+ text = raw.trim();
303
+ } catch {
304
+ return { detail: "no last message written", isError: true, text: "" };
305
+ }
306
+ if (text === "") {
307
+ return { detail: "empty last message", isError: true, text: "" };
308
+ }
309
+ return { isError: false, text };
310
+ };
311
+
312
+ const verdictSchema = z.object({
313
+ missing: z.array(z.string()).default([]),
314
+ notes: z.string().default(""),
315
+ pass: z.boolean(),
316
+ score: z.number().min(0).max(1).optional(),
317
+ });
318
+
319
+ /** The judge's grade for one answer. */
320
+ export type Verdict = z.infer<typeof verdictSchema>;
321
+
322
+ /**
323
+ * Extract the verdict JSON from a judge reply. Tolerates markdown fences and
324
+ * surrounding prose; returns undefined when no valid verdict can be found.
325
+ */
326
+ export const parseVerdict = (text: string): Verdict | undefined => {
327
+ const start = text.indexOf("{");
328
+ const end = text.lastIndexOf("}");
329
+ if (start === -1 || end <= start) {
330
+ return;
331
+ }
332
+ let parsed: unknown;
333
+ try {
334
+ parsed = JSON.parse(text.slice(start, end + 1));
335
+ } catch {
336
+ return;
337
+ }
338
+ const result = verdictSchema.safeParse(parsed);
339
+ return result.success ? result.data : undefined;
340
+ };