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,70 @@
1
+ import { spawn } from "bun";
2
+
3
+ import type { Config, Registry } from "../config/schema.ts";
4
+
5
+ /**
6
+ * Injectable command runner for the quality gate. Tests fake this; production uses
7
+ * `defaultGateExec`. Returns the process exit code and the combined stdout+stderr.
8
+ */
9
+ export type GateExec = (command: string, cwd: string) => Promise<{ exitCode: number; output: string }>;
10
+
11
+ export interface GateResult {
12
+ output: string;
13
+ passed: boolean;
14
+ }
15
+
16
+ const MAX_OUTPUT_CHARS = 16000;
17
+
18
+ /**
19
+ * Project-over-default resolution of the per-project quality-gate commands. Returns
20
+ * `[]` when neither layer sets `qualityGate.commands` (⇒ the gate is off).
21
+ */
22
+ export function resolveQualityGate(config: Config, registry: Registry, projectKey: string): string[] {
23
+ const projectCommands = registry.projects[projectKey]?.qualityGate?.commands;
24
+ const globalCommands = config.defaults.qualityGate?.commands;
25
+ return projectCommands ?? globalCommands ?? [];
26
+ }
27
+
28
+ /** Truncate from the front, keeping the tail (where errors usually surface). */
29
+ function truncate(text: string): string {
30
+ if (text.length <= MAX_OUTPUT_CHARS) {
31
+ return text;
32
+ }
33
+ return `…(truncated)…\n${text.slice(text.length - MAX_OUTPUT_CHARS)}`;
34
+ }
35
+
36
+ /**
37
+ * Run each command sequentially in `cwd`. The FIRST non-zero exit makes `passed:false`
38
+ * and stops (later commands are not run); the combined stdout+stderr of every command
39
+ * run so far is captured into `output` (truncated to a sane cap).
40
+ */
41
+ export async function runQualityGate(commands: string[], cwd: string, exec: GateExec): Promise<GateResult> {
42
+ const chunks: string[] = [];
43
+ for (const command of commands) {
44
+ const ran = await exec(command, cwd);
45
+ chunks.push(`$ ${command}\n${ran.output}`);
46
+ if (ran.exitCode !== 0) {
47
+ return { output: truncate(chunks.join("\n\n")), passed: false };
48
+ }
49
+ }
50
+ return { output: truncate(chunks.join("\n\n")), passed: true };
51
+ }
52
+
53
+ /**
54
+ * Default runner: shell-parse the command string into an argv and spawn it in `cwd`,
55
+ * mirroring `bunExec` (no shell, argv array). Combines stdout and stderr into `output`.
56
+ */
57
+ export async function defaultGateExec(command: string, cwd: string): Promise<{ exitCode: number; output: string }> {
58
+ const parts = command.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) ?? [];
59
+ const argv = parts.map((p) => p.replace(/^["']|["']$/g, ""));
60
+ if (argv.length === 0) {
61
+ return { exitCode: 0, output: "" };
62
+ }
63
+ const proc = spawn(argv, { cwd, stderr: "pipe", stdout: "pipe" });
64
+ const [stdout, stderr, exitCode] = await Promise.all([
65
+ new Response(proc.stdout).text(),
66
+ new Response(proc.stderr).text(),
67
+ proc.exited,
68
+ ]);
69
+ return { exitCode, output: stdout + stderr };
70
+ }
@@ -0,0 +1,40 @@
1
+ import type { Registry } from "../config/schema.ts";
2
+ import type { Tracker } from "../trackers/tracker.ts";
3
+
4
+ export interface QueueRow {
5
+ project: string;
6
+ key: string;
7
+ title: string;
8
+ state: string;
9
+ priority?: string;
10
+ }
11
+
12
+ export interface QueueDeps {
13
+ tracker: Tracker;
14
+ registry: Registry;
15
+ }
16
+
17
+ export interface QueueOptions {
18
+ projects?: string[];
19
+ state?: string;
20
+ }
21
+
22
+ export async function queueView(deps: QueueDeps, opts: QueueOptions): Promise<QueueRow[]> {
23
+ const projects = opts.projects ?? Object.keys(deps.registry.projects);
24
+ const state = opts.state ?? "Todo";
25
+
26
+ const rows: QueueRow[] = [];
27
+ for (const project of projects) {
28
+ const issues = await deps.tracker.listQueue({ project, state });
29
+ for (const issue of issues) {
30
+ rows.push({
31
+ key: issue.key,
32
+ project,
33
+ state: issue.state.name,
34
+ title: issue.title,
35
+ ...(issue.priority !== undefined ? { priority: issue.priority } : {}),
36
+ });
37
+ }
38
+ }
39
+ return rows;
40
+ }
@@ -0,0 +1,266 @@
1
+ import { existsSync } from "node:fs";
2
+
3
+ import { z } from "zod";
4
+
5
+ import { resolveAcpCommand } from "../agent/acpx.ts";
6
+ import type { AgentDriver } from "../agent/driver.ts";
7
+ import type { Config, Registry } from "../config/schema.ts";
8
+ import type { Tracker } from "../trackers/tracker.ts";
9
+ import type { PromptSet } from "./prompts.ts";
10
+ import { renderReviewContract } from "./prompts.ts";
11
+ import { resolveRun } from "./run.ts";
12
+ import type { Logger, ResolvedRun } from "./run.ts";
13
+ import { loadRecord, resolveRunsDir, saveRecord, systemClock } from "./runstore.ts";
14
+ import type { Clock, RunStoreFs } from "./runstore.ts";
15
+ import { bunExec, createWorktree, resolveWorktreeDir } from "./worktree.ts";
16
+ import type { Exec } from "./worktree.ts";
17
+
18
+ const IN_REVIEW_STATE = "In Review";
19
+ const SECONDS_PER_MINUTE = 60;
20
+
21
+ export const reviewFindingSchema = z.object({
22
+ comment: z.string(),
23
+ file: z.string().optional(),
24
+ line: z.number().optional(),
25
+ severity: z.enum(["blocker", "major", "minor", "nit"]),
26
+ });
27
+
28
+ export const reviewReportSchema = z.object({
29
+ findings: z.array(reviewFindingSchema),
30
+ summary: z.string(),
31
+ });
32
+
33
+ export type ReviewFinding = z.infer<typeof reviewFindingSchema>;
34
+ export type ReviewReport = z.infer<typeof reviewReportSchema>;
35
+
36
+ // Mirrors `extractReport`: the LAST fenced block whose info string is exactly
37
+ // `beflow-review` (tolerating trailing whitespace + CRLF) is parsed and validated.
38
+ const blockPattern = /```beflow-review[^\S\r\n]*\r?\n([\s\S]*?)```/g;
39
+
40
+ export function extractReviewReport(text: string): ReviewReport | null {
41
+ let inner: string | null = null;
42
+ for (const match of text.matchAll(blockPattern)) {
43
+ inner = match[1] ?? null;
44
+ }
45
+ if (inner === null) {
46
+ return null;
47
+ }
48
+
49
+ let parsed: unknown;
50
+ try {
51
+ parsed = JSON.parse(inner);
52
+ } catch {
53
+ return null;
54
+ }
55
+
56
+ const result = reviewReportSchema.safeParse(parsed);
57
+ return result.success ? result.data : null;
58
+ }
59
+
60
+ // Posts a comment on the PR (never the issue). Injectable so tests can spy on it
61
+ // Without a real `gh`.
62
+ export type PrCommenter = (prUrl: string, body: string) => Promise<void>;
63
+
64
+ export async function defaultPrComment(prUrl: string, body: string): Promise<void> {
65
+ await bunExec("gh", ["pr", "comment", prUrl, "--body", body]);
66
+ }
67
+
68
+ // Source of the PR head SHA, used to skip re-review of an unchanged head. Mirrors
69
+ // The `prChecks` seam in watch (whose result carries the head SHA).
70
+ export type ReviewSha = (prUrl: string) => Promise<string | undefined>;
71
+
72
+ // Project-over-default resolution of the per-project review toggles.
73
+ export function resolveReviewEnabled(config: Config, registry: Registry, projectKey: string): boolean {
74
+ return registry.projects[projectKey]?.review?.enabled ?? config.defaults.review?.enabled ?? false;
75
+ }
76
+
77
+ export function resolveReviewPostToPr(config: Config, registry: Registry, projectKey: string): boolean {
78
+ return registry.projects[projectKey]?.review?.postToPr ?? config.defaults.review?.postToPr ?? false;
79
+ }
80
+
81
+ function projectKeyOf(issueKey: string): string {
82
+ const dash = issueKey.lastIndexOf("-");
83
+ if (dash === -1) {
84
+ throw new Error(`beflow: malformed issue key "${issueKey}"`);
85
+ }
86
+ return issueKey.slice(0, dash);
87
+ }
88
+
89
+ const SEVERITY_RANK: Record<ReviewFinding["severity"], number> = { blocker: 0, major: 1, minor: 2, nit: 3 };
90
+
91
+ function formatReviewBody(report: ReviewReport): string {
92
+ const lines = [`## Review\n`, report.summary];
93
+ if (report.findings.length === 0) {
94
+ lines.push("\nNo findings — the PR looks clean.");
95
+ return lines.join("\n");
96
+ }
97
+ lines.push("\n### Findings");
98
+ const ordered = [...report.findings].sort((a, b) => SEVERITY_RANK[a.severity] - SEVERITY_RANK[b.severity]);
99
+ for (const f of ordered) {
100
+ const location =
101
+ f.file !== undefined ? (f.line !== undefined ? `${f.file}:${String(f.line)}` : f.file) : undefined;
102
+ const where = location !== undefined ? `${location} — ` : "";
103
+ lines.push(`- [${f.severity}] ${where}${f.comment}`);
104
+ }
105
+ return lines.join("\n");
106
+ }
107
+
108
+ export interface ReviewResult {
109
+ reviewed: boolean;
110
+ findings?: number;
111
+ reason?: string;
112
+ }
113
+
114
+ export interface RunReviewDeps {
115
+ tracker: Tracker;
116
+ driver: AgentDriver;
117
+ config: Config;
118
+ registry: Registry;
119
+ prompts: PromptSet;
120
+ git?: Exec;
121
+ log?: Logger;
122
+ runsFs?: RunStoreFs;
123
+ clock?: Clock;
124
+ pathExists?: (p: string) => boolean;
125
+ preResolved?: ResolvedRun;
126
+ prCommenter?: PrCommenter;
127
+ reviewSha?: ReviewSha;
128
+ postToPr?: boolean;
129
+ }
130
+
131
+ // Agent-driven PR review: a reviewer agent reads the PR diff and emits a structured
132
+ // `beflow-review` block. The findings are posted to the ISSUE always, and to the PR
133
+ // When opted in. NEVER changes board state, NEVER merges or pushes. Degrade-safe: any
134
+ // Precondition miss or gh/tracker error is logged and returns rather than throwing.
135
+ export async function runReview(key: string, deps: RunReviewDeps): Promise<ReviewResult> {
136
+ const log =
137
+ deps.log ??
138
+ ((): void => {
139
+ /* no-op: logging disabled */
140
+ });
141
+ const clock = deps.clock ?? systemClock;
142
+ const pathExists = deps.pathExists ?? existsSync;
143
+
144
+ let resolved: ResolvedRun;
145
+ try {
146
+ resolved = deps.preResolved ?? (await resolveRun(key, {}, deps.config, deps.registry, deps.tracker));
147
+ } catch (err) {
148
+ log(`beflow: review ${key} — could not resolve: ${err instanceof Error ? err.message : String(err)}`);
149
+ return { reason: "resolve-failed", reviewed: false };
150
+ }
151
+ const { issue } = resolved;
152
+
153
+ if (issue.state.name !== IN_REVIEW_STATE && issue.state.group !== "started") {
154
+ log(`beflow: review ${key} — not In Review (${issue.state.name}); skipping`);
155
+ return { reason: "not-in-review", reviewed: false };
156
+ }
157
+
158
+ const runsDir = resolveRunsDir(deps.config.runs?.dir);
159
+ const record = loadRecord(runsDir, key, deps.runsFs);
160
+ if (record?.prUrl === undefined) {
161
+ log(`beflow: review ${key} — no PR on record; skipping`);
162
+ return { reason: "no-pr", reviewed: false };
163
+ }
164
+ const { prUrl } = record;
165
+
166
+ // Reuse the implement worktree when it survives; otherwise carve a fresh one off
167
+ // The repo so the reviewer agent has the diff locally for `gh pr diff`.
168
+ let cwd: string;
169
+ let createdWorktree = false;
170
+ if (record.cwd !== "" && pathExists(record.cwd)) {
171
+ cwd = record.cwd;
172
+ } else if (deps.git !== undefined) {
173
+ const baseDir = resolveWorktreeDir(deps.config.worktrees?.dir);
174
+ try {
175
+ cwd = await createWorktree(record.repoPath ?? resolved.resolved.repoPath, key, deps.git, baseDir);
176
+ createdWorktree = true;
177
+ } catch (err) {
178
+ log(
179
+ `beflow: review ${key} — could not prepare a worktree: ${err instanceof Error ? err.message : String(err)}`,
180
+ );
181
+ return { reason: "no-worktree", reviewed: false };
182
+ }
183
+ } else {
184
+ cwd = resolved.resolved.repoPath;
185
+ }
186
+ if (createdWorktree) {
187
+ log(`beflow: review ${key} — created worktree at ${cwd}`);
188
+ }
189
+
190
+ const sessionKey = `${key}:review`;
191
+ const acpCommand = resolveAcpCommand(resolved.resolved.agent, deps.config.agents[resolved.resolved.agent]);
192
+ const contract = renderReviewContract(deps.prompts, issue, resolved.resolved.repo);
193
+ const maxRunMinutes = deps.registry.projects[projectKeyOf(key)]?.limits?.maxRunMinutes ?? 0;
194
+
195
+ let assistantText: string;
196
+ try {
197
+ await deps.driver.ensureSession(sessionKey, cwd, acpCommand);
198
+ const result = await deps.driver.run(
199
+ {
200
+ acpCommand,
201
+ contract,
202
+ cwd,
203
+ nonInteractive: "fail",
204
+ runMode: "autonomous",
205
+ sessionKey,
206
+ task: contract,
207
+ ...(maxRunMinutes > 0 ? { timeoutSeconds: maxRunMinutes * SECONDS_PER_MINUTE } : {}),
208
+ },
209
+ (evt) => {
210
+ log(`acpx: ${JSON.stringify(evt)}`);
211
+ },
212
+ );
213
+ assistantText = result.stream.assistantText;
214
+ } catch (err) {
215
+ log(`beflow: review ${key} — agent dispatch failed: ${err instanceof Error ? err.message : String(err)}`);
216
+ return { reason: "dispatch-failed", reviewed: false };
217
+ }
218
+
219
+ const report = extractReviewReport(assistantText);
220
+ if (report === null) {
221
+ log(`beflow: review ${key} — agent produced no review block; skipping`);
222
+ return { reason: "no-report", reviewed: false };
223
+ }
224
+
225
+ const body = formatReviewBody(report);
226
+ try {
227
+ await deps.tracker.comment(issue, body);
228
+ } catch (err) {
229
+ log(`beflow: review ${key} — issue comment failed: ${err instanceof Error ? err.message : String(err)}`);
230
+ }
231
+
232
+ const postToPr = deps.postToPr ?? resolveReviewPostToPr(deps.config, deps.registry, projectKeyOf(key));
233
+ if (postToPr) {
234
+ const prCommenter = deps.prCommenter ?? defaultPrComment;
235
+ try {
236
+ await prCommenter(prUrl, body);
237
+ } catch (err) {
238
+ log(`beflow: review ${key} — PR comment failed: ${err instanceof Error ? err.message : String(err)}`);
239
+ }
240
+ }
241
+
242
+ // Record the reviewed head so a later watch pass skips an unchanged PR. Obtain the
243
+ // SHA from the injected source; fall back to keeping the prior value when unknown.
244
+ let reviewedSha: string | undefined = record.reviewedSha;
245
+ if (deps.reviewSha !== undefined) {
246
+ try {
247
+ reviewedSha = (await deps.reviewSha(prUrl)) ?? reviewedSha;
248
+ } catch (err) {
249
+ log(`beflow: review ${key} — head SHA lookup failed: ${err instanceof Error ? err.message : String(err)}`);
250
+ }
251
+ }
252
+ saveRecord(
253
+ runsDir,
254
+ {
255
+ ...record,
256
+ updatedAt: clock(),
257
+ ...(reviewedSha !== undefined ? { reviewedSha } : {}),
258
+ },
259
+ deps.runsFs,
260
+ );
261
+
262
+ log(
263
+ `beflow: review ${key} — posted ${String(report.findings.length)} finding(s)${postToPr ? " (issue + PR)" : ""}`,
264
+ );
265
+ return { findings: report.findings.length, reviewed: true };
266
+ }