beflow 0.1.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 (69) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +121 -0
  3. package/config.example.json +68 -0
  4. package/config.schema.json +413 -0
  5. package/package.json +72 -0
  6. package/src/agent/acpx.ts +197 -0
  7. package/src/agent/driver.ts +38 -0
  8. package/src/agent/events.ts +228 -0
  9. package/src/agent/issuefence.ts +42 -0
  10. package/src/agent/report.ts +44 -0
  11. package/src/cli.ts +910 -0
  12. package/src/config/load.ts +45 -0
  13. package/src/config/persist.ts +58 -0
  14. package/src/config/schema.ts +181 -0
  15. package/src/config/store.ts +119 -0
  16. package/src/core/accept.ts +25 -0
  17. package/src/core/continuation.ts +57 -0
  18. package/src/core/deadletter.ts +55 -0
  19. package/src/core/decision.ts +8 -0
  20. package/src/core/doctor.ts +223 -0
  21. package/src/core/drift.ts +59 -0
  22. package/src/core/gc.ts +223 -0
  23. package/src/core/inputquality.ts +30 -0
  24. package/src/core/issuetemplate.ts +175 -0
  25. package/src/core/mcp.ts +191 -0
  26. package/src/core/newissue.ts +343 -0
  27. package/src/core/notify.ts +151 -0
  28. package/src/core/prompts.ts +165 -0
  29. package/src/core/qualitygate.ts +70 -0
  30. package/src/core/queue.ts +40 -0
  31. package/src/core/review.ts +266 -0
  32. package/src/core/run.ts +1075 -0
  33. package/src/core/runstore.ts +144 -0
  34. package/src/core/runsview.ts +111 -0
  35. package/src/core/setup.ts +203 -0
  36. package/src/core/sla.ts +39 -0
  37. package/src/core/template.ts +65 -0
  38. package/src/core/watch.ts +825 -0
  39. package/src/core/worktree.ts +74 -0
  40. package/src/core/writeback.ts +88 -0
  41. package/src/index.ts +154 -0
  42. package/src/model/types.ts +35 -0
  43. package/src/prompts/defaults/continuation.md +9 -0
  44. package/src/prompts/defaults/implement.md +13 -0
  45. package/src/prompts/defaults/issue-enrich.md +30 -0
  46. package/src/prompts/defaults/issues/bug.md +35 -0
  47. package/src/prompts/defaults/issues/feature.md +24 -0
  48. package/src/prompts/defaults/issues/generic.md +16 -0
  49. package/src/prompts/defaults/issues/spike.md +24 -0
  50. package/src/prompts/defaults/report.md +20 -0
  51. package/src/prompts/defaults/review.md +34 -0
  52. package/src/prompts/defaults/spec.md +11 -0
  53. package/src/prompts/defaults/task.md +6 -0
  54. package/src/prompts/defaults/triage.md +11 -0
  55. package/src/prompts/text-modules.d.ts +4 -0
  56. package/src/resolve/jobkind.ts +11 -0
  57. package/src/resolve/metadata.ts +103 -0
  58. package/src/resolve/precedence.ts +104 -0
  59. package/src/trackers/factory.ts +17 -0
  60. package/src/trackers/linear/adapter.ts +416 -0
  61. package/src/trackers/linear/client.ts +264 -0
  62. package/src/trackers/linear/map.ts +113 -0
  63. package/src/trackers/linear/types.ts +44 -0
  64. package/src/trackers/marker.ts +20 -0
  65. package/src/trackers/plane/adapter.ts +754 -0
  66. package/src/trackers/plane/client.ts +302 -0
  67. package/src/trackers/plane/map.ts +168 -0
  68. package/src/trackers/plane/types.ts +134 -0
  69. package/src/trackers/tracker.ts +135 -0
@@ -0,0 +1,197 @@
1
+ import { spawn } from "bun";
2
+
3
+ import type { AgentConfig, Config } from "../config/schema.ts";
4
+ import type { AgentDriver, AgentRunResult, RunOptions } from "./driver.ts";
5
+ import { parseAcpLine, reduceAcpStream } from "./events.ts";
6
+ import { extractReport } from "./report.ts";
7
+
8
+ const DEFAULT_ACPX_COMMAND = ["bunx", "acpx"] as const;
9
+
10
+ // Wall-clock grace added on top of acpx's own cooperative `--timeout`. acpx's
11
+ // `--timeout` is the first line of defence (it asks the agent to stop); this
12
+ // Grace is the window we give that cooperative stop before the driver's hard
13
+ // Kill backstops a wedged acpx.
14
+ const GRACE_SECONDS = 30;
15
+
16
+ // The configured acpx launcher (command + leading args), default `bunx acpx`.
17
+ export function resolveAcpxCommand(config: Config): string[] {
18
+ const configured = config.tools?.acpx;
19
+ return configured !== undefined && configured.length > 0 ? configured : [...DEFAULT_ACPX_COMMAND];
20
+ }
21
+
22
+ // Resolve the acpx `--agent` command string for an agent. beflow always fully
23
+ // Specifies the agent in config.agents and never relies on acpx's built-in
24
+ // Registry, so an unconfigured agent is an error.
25
+ export function resolveAcpCommand(agentName: string, cfg: AgentConfig | undefined): string {
26
+ if (cfg === undefined) {
27
+ throw new Error(`beflow: agent "${agentName}" is not configured in config.agents (add it with a "command")`);
28
+ }
29
+ return [cfg.acpCommand ?? cfg.command, ...(cfg.acpArgs ?? [])].join(" ");
30
+ }
31
+
32
+ export function buildAcpxArgs(opts: RunOptions): string[] {
33
+ const args: string[] = ["--format", "json", "--json-strict", "--cwd", opts.cwd];
34
+
35
+ args.push(opts.runMode === "autonomous" ? "--approve-all" : "--approve-reads");
36
+
37
+ if (opts.nonInteractive !== undefined) {
38
+ args.push("--non-interactive-permissions", opts.nonInteractive);
39
+ }
40
+ if (opts.contract !== undefined) {
41
+ args.push("--append-system-prompt", opts.contract);
42
+ }
43
+ if (opts.model !== undefined) {
44
+ args.push("--model", opts.model);
45
+ }
46
+ args.push("--agent", opts.acpCommand);
47
+ if (opts.timeoutSeconds !== undefined) {
48
+ args.push("--timeout", String(opts.timeoutSeconds));
49
+ }
50
+ if (opts.suppressReads === true) {
51
+ args.push("--suppress-reads");
52
+ }
53
+
54
+ if (opts.oneShot === true) {
55
+ args.push("exec", opts.task);
56
+ } else {
57
+ args.push("prompt", "-s", opts.sessionKey, opts.task);
58
+ }
59
+
60
+ return args;
61
+ }
62
+
63
+ export function buildCancelArgs(sessionKey: string, cwd: string, acpCommand: string): string[] {
64
+ return ["--cwd", cwd, "--agent", acpCommand, "cancel", "-s", sessionKey];
65
+ }
66
+
67
+ export function buildEnsureSessionArgs(sessionName: string, cwd: string, acpCommand: string): string[] {
68
+ return ["--cwd", cwd, "--agent", acpCommand, "sessions", "ensure", "--name", sessionName];
69
+ }
70
+
71
+ export interface SpawnedProcess {
72
+ lines(): AsyncIterable<string>;
73
+ exit(): Promise<number>;
74
+ kill(): void;
75
+ }
76
+
77
+ export interface ProcessRunner {
78
+ spawn(args: string[], cwd: string): SpawnedProcess;
79
+ }
80
+
81
+ export class BunProcessRunner implements ProcessRunner {
82
+ public constructor(private readonly command: string[] = ["bunx", "acpx"]) {}
83
+
84
+ public spawn(args: string[], cwd: string): SpawnedProcess {
85
+ const proc = spawn([...this.command, ...args], {
86
+ cwd,
87
+ stderr: "inherit",
88
+ stdout: "pipe",
89
+ });
90
+
91
+ async function* lines(): AsyncIterable<string> {
92
+ const decoder = new TextDecoder();
93
+ let buffer = "";
94
+ for await (const chunk of proc.stdout) {
95
+ buffer += decoder.decode(chunk, { stream: true });
96
+ let newline = buffer.indexOf("\n");
97
+ while (newline !== -1) {
98
+ yield buffer.slice(0, newline);
99
+ buffer = buffer.slice(newline + 1);
100
+ newline = buffer.indexOf("\n");
101
+ }
102
+ }
103
+ buffer += decoder.decode();
104
+ if (buffer !== "") {
105
+ yield buffer;
106
+ }
107
+ }
108
+
109
+ return {
110
+ exit: async () => proc.exited,
111
+ kill: () => {
112
+ proc.kill();
113
+ },
114
+ lines,
115
+ };
116
+ }
117
+ }
118
+
119
+ async function realDelay(ms: number): Promise<void> {
120
+ return new Promise((resolve) => {
121
+ setTimeout(resolve, ms);
122
+ });
123
+ }
124
+
125
+ const MS_PER_SECOND = 1000;
126
+
127
+ export class AcpxDriver implements AgentDriver {
128
+ private readonly runner: ProcessRunner;
129
+ private readonly delay: (ms: number) => Promise<void>;
130
+
131
+ public constructor(
132
+ opts: { runner?: ProcessRunner; command?: string[]; delay?: (ms: number) => Promise<void> } = {},
133
+ ) {
134
+ this.runner = opts.runner ?? new BunProcessRunner(opts.command);
135
+ this.delay = opts.delay ?? realDelay;
136
+ }
137
+
138
+ public async run(opts: RunOptions, onEvent?: (evt: unknown) => void): Promise<AgentRunResult> {
139
+ const proc = this.runner.spawn(buildAcpxArgs(opts), opts.cwd);
140
+
141
+ const raw: string[] = [];
142
+ async function consume(): Promise<void> {
143
+ for await (const line of proc.lines()) {
144
+ raw.push(line);
145
+ if (onEvent !== undefined) {
146
+ const evt = parseAcpLine(line);
147
+ if (evt !== null) {
148
+ onEvent(evt);
149
+ }
150
+ }
151
+ }
152
+ }
153
+
154
+ let timedOut = false;
155
+ const consumed = consume();
156
+ if (opts.timeoutSeconds === undefined) {
157
+ await consumed;
158
+ } else {
159
+ const deadlineMs = (opts.timeoutSeconds + GRACE_SECONDS) * MS_PER_SECOND;
160
+ const deadline = Symbol("deadline");
161
+ const winner = await Promise.race([consumed, this.delay(deadlineMs).then(() => deadline)]);
162
+ if (winner === deadline) {
163
+ timedOut = true;
164
+ // Hard kill: ending stdout terminates the in-flight `consume()` line
165
+ // Iteration cleanly, so we await the SAME promise to drain it and avoid
166
+ // A dangling unhandled rejection.
167
+ proc.kill();
168
+ await consumed;
169
+ }
170
+ }
171
+
172
+ // OnEvent already fired live above; reduce the buffered lines for the result.
173
+ const stream = reduceAcpStream(raw);
174
+ const report = extractReport(stream.assistantText);
175
+ const exitCode = await proc.exit();
176
+
177
+ return { exitCode, raw, report, stream, timedOut };
178
+ }
179
+
180
+ public async cancel(sessionKey: string, cwd: string, acpCommand: string): Promise<void> {
181
+ const proc = this.runner.spawn(buildCancelArgs(sessionKey, cwd, acpCommand), cwd);
182
+ await proc.exit();
183
+ }
184
+
185
+ public async ensureSession(sessionName: string, cwd: string, acpCommand: string): Promise<void> {
186
+ const proc = this.runner.spawn(buildEnsureSessionArgs(sessionName, cwd, acpCommand), cwd);
187
+ for await (const line of proc.lines()) {
188
+ void line; // drain stdout
189
+ }
190
+ const exitCode = await proc.exit();
191
+ if (exitCode !== 0) {
192
+ throw new Error(
193
+ `beflow: acpx sessions ensure failed for session "${sessionName}" (exit ${String(exitCode)})`,
194
+ );
195
+ }
196
+ }
197
+ }
@@ -0,0 +1,38 @@
1
+ import type { RunMode } from "../model/types.ts";
2
+ import type { AcpStreamResult } from "./events.ts";
3
+ import type { Report } from "./report.ts";
4
+
5
+ // The agent-execution contract: the inputs, the result, and the surface that
6
+ // consumers depend on. Kept separate from the concrete acpx driver so callers
7
+ // and tests depend on the interface, not the implementation's private internals.
8
+
9
+ export interface RunOptions {
10
+ sessionKey: string;
11
+ cwd: string;
12
+ runMode: RunMode;
13
+ task: string;
14
+ contract?: string;
15
+ model?: string;
16
+ // ACP-server command passed to acpx via `--agent`. Always set by beflow.
17
+ acpCommand: string;
18
+ oneShot?: boolean;
19
+ nonInteractive?: "deny" | "fail";
20
+ timeoutSeconds?: number;
21
+ suppressReads?: boolean;
22
+ }
23
+
24
+ export interface AgentRunResult {
25
+ exitCode: number;
26
+ stream: AcpStreamResult;
27
+ report: Report | null;
28
+ raw: string[];
29
+ // True when the driver hit its hard wall-clock deadline and killed the process
30
+ // before it completed. False on every normal completion.
31
+ timedOut: boolean;
32
+ }
33
+
34
+ export interface AgentDriver {
35
+ run: (opts: RunOptions, onEvent?: (evt: unknown) => void) => Promise<AgentRunResult>;
36
+ cancel: (sessionKey: string, cwd: string, acpCommand: string) => Promise<void>;
37
+ ensureSession: (sessionName: string, cwd: string, acpCommand: string) => Promise<void>;
38
+ }
@@ -0,0 +1,228 @@
1
+ export interface AcpToolCall {
2
+ id: string;
3
+ title?: string;
4
+ kind?: string;
5
+ status?: string;
6
+ toolName?: string;
7
+ }
8
+
9
+ export interface Usage {
10
+ cacheReadTokens?: number;
11
+ cacheWriteTokens?: number;
12
+ costUsd?: number;
13
+ inputTokens?: number;
14
+ outputTokens?: number;
15
+ totalTokens?: number;
16
+ }
17
+
18
+ export interface AcpStreamResult {
19
+ assistantText: string;
20
+ toolCalls: AcpToolCall[];
21
+ stopReason?: string;
22
+ error?: { code?: number; message: string };
23
+ usage?: Usage;
24
+ }
25
+
26
+ export function parseAcpLine(line: string): unknown {
27
+ const trimmed = line.trim();
28
+ if (trimmed === "") {
29
+ return null;
30
+ }
31
+ try {
32
+ return JSON.parse(trimmed);
33
+ } catch {
34
+ return null;
35
+ }
36
+ }
37
+
38
+ interface ToolName {
39
+ _meta?: { claudeCode?: { toolName?: unknown } };
40
+ }
41
+
42
+ function readToolName(update: ToolName): string | undefined {
43
+ const name = update._meta?.claudeCode?.toolName;
44
+ return typeof name === "string" ? name : undefined;
45
+ }
46
+
47
+ function asString(value: unknown): string | undefined {
48
+ return typeof value === "string" ? value : undefined;
49
+ }
50
+
51
+ function asNumber(value: unknown): number | undefined {
52
+ if (typeof value === "number" && Number.isFinite(value)) {
53
+ return value;
54
+ }
55
+ if (typeof value === "string") {
56
+ const parsed = Number(value);
57
+ return Number.isFinite(parsed) ? parsed : undefined;
58
+ }
59
+ return undefined;
60
+ }
61
+
62
+ function isRecord(value: unknown): value is Record<string, unknown> {
63
+ return typeof value === "object" && value !== null;
64
+ }
65
+
66
+ function asRecord(value: unknown): Record<string, unknown> | undefined {
67
+ return isRecord(value) ? value : undefined;
68
+ }
69
+
70
+ function handleToolCall(toolCalls: AcpToolCall[], update: Record<string, unknown>): void {
71
+ const id = asString(update.toolCallId);
72
+ if (id === undefined) {
73
+ return;
74
+ }
75
+ toolCalls.push({
76
+ id,
77
+ kind: asString(update.kind),
78
+ status: asString(update.status),
79
+ title: asString(update.title),
80
+ toolName: readToolName(update as ToolName),
81
+ });
82
+ }
83
+
84
+ function handleToolCallUpdate(toolCalls: AcpToolCall[], update: Record<string, unknown>): void {
85
+ const id = asString(update.toolCallId);
86
+ if (id === undefined) {
87
+ return;
88
+ }
89
+
90
+ const status = asString(update.status);
91
+ const title = asString(update.title);
92
+ const toolName = readToolName(update as ToolName);
93
+
94
+ const existing = toolCalls.find((call) => call.id === id);
95
+ if (existing === undefined) {
96
+ toolCalls.push({ id, status, title, toolName });
97
+ return;
98
+ }
99
+ if (status !== undefined) {
100
+ existing.status = status;
101
+ }
102
+ if (title !== undefined) {
103
+ existing.title = title;
104
+ }
105
+ if (toolName !== undefined) {
106
+ existing.toolName = toolName;
107
+ }
108
+ }
109
+
110
+ // Lenient `usage_update` parse: the on-wire shape is unverified, so read token
111
+ // Counts defensively from either a nested `usage` object or the top-level update,
112
+ // Accepting camelCase AND snake_case. Any missing/non-numeric field is left
113
+ // Undefined; this NEVER throws. Returns undefined when no field was readable.
114
+ function parseUsage(update: Record<string, unknown>): Usage | undefined {
115
+ const src = asRecord(update.usage) ?? update;
116
+ const inputTokens = asNumber(src.inputTokens) ?? asNumber(src.input_tokens);
117
+ const outputTokens = asNumber(src.outputTokens) ?? asNumber(src.output_tokens);
118
+ const totalTokens = asNumber(src.totalTokens) ?? asNumber(src.total_tokens) ?? asNumber(src.used);
119
+ const cacheReadTokens =
120
+ asNumber(src.cachedReadTokens) ??
121
+ asNumber(src.cacheReadTokens) ??
122
+ asNumber(src.cache_read_tokens) ??
123
+ asNumber(src.cacheReadInputTokens) ??
124
+ asNumber(src.cache_read_input_tokens);
125
+ const cacheWriteTokens =
126
+ asNumber(src.cachedWriteTokens) ??
127
+ asNumber(src.cacheWriteTokens) ??
128
+ asNumber(src.cache_write_tokens) ??
129
+ asNumber(src.cacheCreationInputTokens) ??
130
+ asNumber(src.cache_creation_input_tokens);
131
+ const costUsd =
132
+ asNumber(src.costUsd) ??
133
+ asNumber(src.cost_usd) ??
134
+ asNumber(src.total_cost) ??
135
+ asNumber(src.cost) ??
136
+ asNumber(asRecord(src.cost)?.amount);
137
+
138
+ const usage: Usage = {
139
+ ...(cacheReadTokens !== undefined ? { cacheReadTokens } : {}),
140
+ ...(cacheWriteTokens !== undefined ? { cacheWriteTokens } : {}),
141
+ ...(costUsd !== undefined ? { costUsd } : {}),
142
+ ...(inputTokens !== undefined ? { inputTokens } : {}),
143
+ ...(outputTokens !== undefined ? { outputTokens } : {}),
144
+ ...(totalTokens !== undefined ? { totalTokens } : {}),
145
+ };
146
+ return Object.keys(usage).length > 0 ? usage : undefined;
147
+ }
148
+
149
+ // Last-writer-wins merge: a later usage_update field overrides an earlier one,
150
+ // But a field absent from the new event keeps its prior value.
151
+ function mergeUsage(prior: Usage | undefined, next: Usage): Usage {
152
+ return { ...prior, ...next };
153
+ }
154
+
155
+ function handleSessionUpdate(result: AcpStreamResult, update: Record<string, unknown>): void {
156
+ switch (update.sessionUpdate) {
157
+ case "agent_message_chunk": {
158
+ const content = asRecord(update.content);
159
+ if (content?.type === "text" && typeof content.text === "string") {
160
+ result.assistantText += content.text;
161
+ }
162
+ return;
163
+ }
164
+ case "tool_call":
165
+ handleToolCall(result.toolCalls, update);
166
+ return;
167
+ case "tool_call_update":
168
+ handleToolCallUpdate(result.toolCalls, update);
169
+ return;
170
+ case "usage_update": {
171
+ const parsed = parseUsage(update);
172
+ if (parsed !== undefined) {
173
+ result.usage = mergeUsage(result.usage, parsed);
174
+ }
175
+ return;
176
+ }
177
+ // Unknown sessionUpdate types (agent_thought_chunk, plan,
178
+ // Available_commands_update, …) are ignored by design.
179
+ default:
180
+ return;
181
+ }
182
+ }
183
+
184
+ export function reduceAcpStream(lines: Iterable<string>, onEvent?: (evt: unknown) => void): AcpStreamResult {
185
+ const result: AcpStreamResult = { assistantText: "", toolCalls: [] };
186
+
187
+ for (const line of lines) {
188
+ const evt = parseAcpLine(line);
189
+ if (evt === null) {
190
+ continue;
191
+ }
192
+ onEvent?.(evt);
193
+
194
+ const obj = asRecord(evt);
195
+ if (obj === undefined) {
196
+ continue;
197
+ }
198
+
199
+ if (obj.method === "session/update") {
200
+ const update = asRecord(asRecord(obj.params)?.update);
201
+ if (update !== undefined) {
202
+ handleSessionUpdate(result, update);
203
+ }
204
+ continue;
205
+ }
206
+
207
+ const resultField = asRecord(obj.result);
208
+ if (resultField !== undefined && typeof resultField.stopReason === "string") {
209
+ result.stopReason = resultField.stopReason;
210
+ }
211
+ if (resultField !== undefined) {
212
+ const resultUsage = parseUsage(resultField);
213
+ if (resultUsage !== undefined) {
214
+ result.usage = mergeUsage(result.usage, resultUsage);
215
+ }
216
+ }
217
+
218
+ const errorField = asRecord(obj.error);
219
+ if (errorField !== undefined && typeof errorField.message === "string") {
220
+ result.error = {
221
+ message: errorField.message,
222
+ ...(typeof errorField.code === "number" ? { code: errorField.code } : {}),
223
+ };
224
+ }
225
+ }
226
+
227
+ return result;
228
+ }
@@ -0,0 +1,42 @@
1
+ import { z } from "zod";
2
+
3
+ export interface IssueFence {
4
+ body: string;
5
+ title?: string;
6
+ type?: string;
7
+ priority?: string;
8
+ labels?: string[];
9
+ }
10
+
11
+ const issueFenceSchema = z.object({
12
+ body: z.string(),
13
+ labels: z.array(z.string()).optional(),
14
+ priority: z.enum(["urgent", "high", "medium", "low", "none"]).optional(),
15
+ title: z.string().optional(),
16
+ type: z.string().optional(),
17
+ });
18
+
19
+ // Matches a fenced block whose info string is exactly `beflow-issue` (tolerating
20
+ // trailing whitespace after it and CRLF line endings). The `g` flag lets us take
21
+ // the LAST block — the agent's final word.
22
+ const blockPattern = /```beflow-issue[^\S\r\n]*\r?\n([\s\S]*?)```/g;
23
+
24
+ export function extractIssueFence(text: string): IssueFence | null {
25
+ let inner: string | null = null;
26
+ for (const match of text.matchAll(blockPattern)) {
27
+ inner = match[1] ?? null;
28
+ }
29
+ if (inner === null) {
30
+ return null;
31
+ }
32
+
33
+ let parsed: unknown;
34
+ try {
35
+ parsed = JSON.parse(inner);
36
+ } catch {
37
+ return null;
38
+ }
39
+
40
+ const result = issueFenceSchema.safeParse(parsed);
41
+ return result.success ? result.data : null;
42
+ }
@@ -0,0 +1,44 @@
1
+ import { z } from "zod";
2
+
3
+ export type ReportStatus = "done" | "needs_input" | "blocked" | "failed";
4
+
5
+ export interface Report {
6
+ status: ReportStatus;
7
+ summary: string;
8
+ prUrl?: string;
9
+ questions?: string[];
10
+ notes?: string;
11
+ }
12
+
13
+ const reportSchema = z.object({
14
+ notes: z.string().optional(),
15
+ prUrl: z.string().optional(),
16
+ questions: z.array(z.string()).optional(),
17
+ status: z.enum(["done", "needs_input", "blocked", "failed"]),
18
+ summary: z.string(),
19
+ });
20
+
21
+ // Matches a fenced block whose info string is exactly `beflow-report`
22
+ // (tolerating trailing whitespace after it and CRLF line endings). The `g`
23
+ // Flag lets us take the LAST block — the agent's final word.
24
+ const blockPattern = /```beflow-report[^\S\r\n]*\r?\n([\s\S]*?)```/g;
25
+
26
+ export function extractReport(text: string): Report | null {
27
+ let inner: string | null = null;
28
+ for (const match of text.matchAll(blockPattern)) {
29
+ inner = match[1] ?? null;
30
+ }
31
+ if (inner === null) {
32
+ return null;
33
+ }
34
+
35
+ let parsed: unknown;
36
+ try {
37
+ parsed = JSON.parse(inner);
38
+ } catch {
39
+ return null;
40
+ }
41
+
42
+ const result = reportSchema.safeParse(parsed);
43
+ return result.success ? result.data : null;
44
+ }