diff --git a/src/beads-rust/client.ts b/src/beads-rust/client.ts new file mode 100644 index 00000000..25f8fc03 --- /dev/null +++ b/src/beads-rust/client.ts @@ -0,0 +1,227 @@ +/** + * Beads Rust (br) CLI client. + * + * Wraps the `br` command-line tool for issue tracking operations. + * All commands use `--json` for parseable output where supported. + * Uses Bun.spawn — zero runtime dependencies. + * + * Key differences from the bd (beads) client: + * - list: returns envelope { issues: [], total, limit, offset, has_more } + * - create: returns single object with id field (not { id } wrapper) + * - claim: atomic via `update --claim` flag + * - sync: requires `--flush-only` mode flag + */ + +import { AgentError } from "../errors.ts"; + +/** + * A beads_rust issue as returned by the br CLI. + * Defined locally since it comes from an external CLI tool. + */ +export interface BeadsRustIssue { + id: string; + title: string; + status: string; + priority: number; + type: string; + assignee?: string; + description?: string; + blocks?: string[]; + blockedBy?: string[]; +} + +export interface BeadsRustClient { + /** List issues that are ready for work (open, unblocked). */ + ready(): Promise; + + /** Show details for a specific issue. */ + show(id: string): Promise; + + /** Create a new issue. Returns the new issue ID. */ + create( + title: string, + options?: { type?: string; priority?: number; description?: string }, + ): Promise; + + /** Claim an issue (atomic: assignee=actor + status=in_progress). */ + claim(id: string): Promise; + + /** Close an issue with an optional reason. */ + close(id: string, reason?: string): Promise; + + /** List issues with optional filters. */ + list(options?: { status?: string; limit?: number }): Promise; + + /** Sync tracker state (export DB to JSONL). */ + sync(): Promise; +} + +/** + * Run a shell command and capture its output. + */ +async function runCommand( + cmd: string[], + cwd: string, +): Promise<{ stdout: string; stderr: string; exitCode: number }> { + const proc = Bun.spawn(cmd, { + cwd, + stdout: "pipe", + stderr: "pipe", + }); + const stdout = await new Response(proc.stdout).text(); + const stderr = await new Response(proc.stderr).text(); + const exitCode = await proc.exited; + return { stdout, stderr, exitCode }; +} + +/** + * Parse JSON output from a br command. + * Handles the case where output may be empty or malformed. + */ +function parseJsonOutput(stdout: string, context: string): T { + const trimmed = stdout.trim(); + if (trimmed === "") { + throw new AgentError(`Empty output from br ${context}`); + } + try { + return JSON.parse(trimmed) as T; + } catch { + throw new AgentError( + `Failed to parse JSON output from br ${context}: ${trimmed.slice(0, 200)}`, + ); + } +} + +/** + * Raw issue shape from the br CLI. + * br uses `issue_type` instead of `type`. + */ +interface RawBrIssue { + id: string; + title: string; + status: string; + priority: number; + issue_type?: string; + type?: string; + assignee?: string; + description?: string; + blocks?: string[]; + blockedBy?: string[]; +} + +/** Envelope for br list --json responses. */ +interface BrListEnvelope { + issues: RawBrIssue[]; + total: number; + limit: number; + offset: number; + has_more: boolean; +} + +/** Shape of br create --json response. */ +interface BrCreateResponse { + id: string; + title: string; + status: string; + [key: string]: unknown; +} + +/** + * Normalize a raw br issue into a BeadsRustIssue. + * Maps `issue_type` -> `type` to match the BeadsRustIssue interface. + */ +function normalizeIssue(raw: RawBrIssue): BeadsRustIssue { + return { + id: raw.id, + title: raw.title, + status: raw.status, + priority: raw.priority, + type: raw.issue_type ?? raw.type ?? "unknown", + assignee: raw.assignee, + description: raw.description, + blocks: raw.blocks, + blockedBy: raw.blockedBy, + }; +} + +/** + * Create a BeadsRustClient bound to the given working directory. + * + * @param cwd - Working directory where br commands should run + * @returns A BeadsRustClient instance wrapping the br CLI + */ +export function createBeadsRustClient(cwd: string): BeadsRustClient { + async function runBr( + args: string[], + context: string, + ): Promise<{ stdout: string; stderr: string }> { + const { stdout, stderr, exitCode } = await runCommand(["br", ...args], cwd); + if (exitCode !== 0) { + throw new AgentError(`br ${context} failed (exit ${exitCode}): ${stderr.trim()}`); + } + return { stdout, stderr }; + } + + return { + async ready() { + const { stdout } = await runBr(["ready", "--json"], "ready"); + const raw = parseJsonOutput(stdout, "ready"); + return raw.map(normalizeIssue); + }, + + async show(id) { + const { stdout } = await runBr(["show", id, "--json"], `show ${id}`); + const raw = parseJsonOutput(stdout, `show ${id}`); + const first = raw[0]; + if (!first) { + throw new AgentError(`br show ${id} returned empty array`); + } + return normalizeIssue(first); + }, + + async create(title, options) { + const args = ["create", title, "--json"]; + if (options?.type) { + args.push("--type", options.type); + } + if (options?.priority !== undefined) { + args.push("--priority", String(options.priority)); + } + if (options?.description) { + args.push("--description", options.description); + } + const { stdout } = await runBr(args, "create"); + const result = parseJsonOutput(stdout, "create"); + return result.id; + }, + + async claim(id) { + await runBr(["update", id, "--claim"], `claim ${id}`); + }, + + async close(id, reason) { + const args = ["close", id]; + if (reason) { + args.push("--reason", reason); + } + await runBr(args, `close ${id}`); + }, + + async list(options) { + const args = ["list", "--json"]; + if (options?.status) { + args.push("--status", options.status); + } + if (options?.limit !== undefined) { + args.push("--limit", String(options.limit)); + } + const { stdout } = await runBr(args, "list"); + const envelope = parseJsonOutput(stdout, "list"); + return envelope.issues.map(normalizeIssue); + }, + + async sync() { + await runBr(["sync", "--flush-only"], "sync"); + }, + }; +} diff --git a/src/config.ts b/src/config.ts index 6c97008b..c15daeb9 100644 --- a/src/config.ts +++ b/src/config.ts @@ -481,6 +481,16 @@ function migrateDeprecatedTaskTrackerKeys(parsed: Record): void process.stderr.write( "[overstory] DEPRECATED: beads: -> use taskTracker: { backend: beads, enabled: true }\n", ); + } else if (parsed.beads_rust !== undefined) { + const brConfig = parsed.beads_rust as Record; + parsed.taskTracker = { + backend: "beads_rust", + enabled: brConfig.enabled ?? true, + }; + delete parsed.beads_rust; + process.stderr.write( + "[overstory] DEPRECATED: beads_rust: -> use taskTracker: { backend: beads_rust, enabled: true }\n", + ); } else if (parsed.seeds !== undefined) { const seedsConfig = parsed.seeds as Record; parsed.taskTracker = { @@ -643,7 +653,7 @@ function validateConfig(config: OverstoryConfig): void { } // taskTracker.backend must be one of the valid options - const validBackends = ["auto", "seeds", "beads"] as const; + const validBackends = ["auto", "seeds", "beads", "beads_rust"] as const; if (!validBackends.includes(config.taskTracker.backend as (typeof validBackends)[number])) { throw new ValidationError(`taskTracker.backend must be one of: ${validBackends.join(", ")}`, { field: "taskTracker.backend", diff --git a/src/tracker/beads-rust.ts b/src/tracker/beads-rust.ts new file mode 100644 index 00000000..bec9e495 --- /dev/null +++ b/src/tracker/beads-rust.ts @@ -0,0 +1,50 @@ +/** + * Beads Rust tracker adapter. + * + * Wraps src/beads-rust/client.ts to implement the unified TrackerClient interface. + */ + +import { createBeadsRustClient } from "../beads-rust/client.ts"; +import type { TrackerClient, TrackerIssue } from "./types.ts"; + +/** + * Create a TrackerClient backed by the beads_rust (br) CLI. + * + * @param cwd - Working directory for br commands + */ +export function createBeadsRustTracker(cwd: string): TrackerClient { + const client = createBeadsRustClient(cwd); + + return { + async ready() { + const issues = await client.ready(); + return issues as TrackerIssue[]; + }, + + async show(id) { + const issue = await client.show(id); + return issue as TrackerIssue; + }, + + async create(title, options) { + return client.create(title, options); + }, + + async claim(id) { + return client.claim(id); + }, + + async close(id, reason) { + return client.close(id, reason); + }, + + async list(options) { + const issues = await client.list(options); + return issues as TrackerIssue[]; + }, + + async sync() { + return client.sync(); + }, + }; +} diff --git a/src/tracker/factory.test.ts b/src/tracker/factory.test.ts index 4dbf6898..c6598642 100644 --- a/src/tracker/factory.test.ts +++ b/src/tracker/factory.test.ts @@ -17,6 +17,18 @@ describe("createTrackerClient", () => { expect(client.sync).toBeTypeOf("function"); }); + test("creates beads_rust tracker for beads_rust backend", () => { + const client = createTrackerClient("beads_rust", "/tmp"); + expect(client).toBeDefined(); + expect(client.ready).toBeTypeOf("function"); + expect(client.show).toBeTypeOf("function"); + expect(client.create).toBeTypeOf("function"); + expect(client.claim).toBeTypeOf("function"); + expect(client.close).toBeTypeOf("function"); + expect(client.list).toBeTypeOf("function"); + expect(client.sync).toBeTypeOf("function"); + }); + test("creates seeds tracker for seeds backend", () => { const client = createTrackerClient("seeds", "/tmp"); expect(client).toBeDefined(); @@ -59,21 +71,28 @@ describe("resolveBackend", () => { await rm(tempDir, { recursive: true }); } }); - test("returns beads for auto when .beads/ exists", async () => { + test("returns beads_rust for auto when .beads/ exists and br is on PATH", async () => { const tempDir = await mkdtemp(join(tmpdir(), "tracker-test-")); try { await mkdir(join(tempDir, ".beads")); - expect(await resolveBackend("auto", tempDir)).toBe("beads"); + // When br is available on PATH, auto-detection prefers beads_rust over beads + const result = await resolveBackend("auto", tempDir); + expect(["beads", "beads_rust"]).toContain(result); } finally { await rm(tempDir, { recursive: true }); } }); - test("returns beads for auto when both .seeds/ and .beads/ exist", async () => { + test("returns beads_rust for explicit beads_rust config", async () => { + expect(await resolveBackend("beads_rust", "/tmp")).toBe("beads_rust"); + }); + test("returns beads or beads_rust for auto when both .seeds/ and .beads/ exist", async () => { const tempDir = await mkdtemp(join(tmpdir(), "tracker-test-")); try { await mkdir(join(tempDir, ".beads")); await mkdir(join(tempDir, ".seeds")); - expect(await resolveBackend("auto", tempDir)).toBe("beads"); + // .beads/ takes precedence over .seeds/; br availability determines beads vs beads_rust + const result = await resolveBackend("auto", tempDir); + expect(["beads", "beads_rust"]).toContain(result); } finally { await rm(tempDir, { recursive: true }); } @@ -84,6 +103,9 @@ describe("trackerCliName", () => { test("returns bd for beads", () => { expect(trackerCliName("beads")).toBe("bd"); }); + test("returns br for beads_rust", () => { + expect(trackerCliName("beads_rust")).toBe("br"); + }); test("returns sd for seeds", () => { expect(trackerCliName("seeds")).toBe("sd"); }); diff --git a/src/tracker/factory.ts b/src/tracker/factory.ts index 4337aab7..3df5db12 100644 --- a/src/tracker/factory.ts +++ b/src/tracker/factory.ts @@ -6,6 +6,7 @@ import { stat } from "node:fs/promises"; import { join } from "node:path"; import type { TaskTrackerBackend } from "../types.ts"; import { createBeadsTracker } from "./beads.ts"; +import { createBeadsRustTracker } from "./beads-rust.ts"; import { createSeedsTracker } from "./seeds.ts"; import type { TrackerBackend, TrackerClient } from "./types.ts"; @@ -19,6 +20,8 @@ export function createTrackerClient(backend: TrackerBackend, cwd: string): Track switch (backend) { case "beads": return createBeadsTracker(cwd); + case "beads_rust": + return createBeadsRustTracker(cwd); case "seeds": return createSeedsTracker(cwd); default: { @@ -37,6 +40,7 @@ export async function resolveBackend( cwd: string, ): Promise { if (configBackend === "beads") return "beads"; + if (configBackend === "beads_rust") return "beads_rust"; if (configBackend === "seeds") return "seeds"; // "auto" detection: check for .beads/ first (never auto-scaffolded by ov init, // so its presence signals explicit user setup), then .seeds/. @@ -48,17 +52,41 @@ export async function resolveBackend( return false; } }; - if (await dirExists(join(cwd, ".beads"))) return "beads"; + if (await dirExists(join(cwd, ".beads"))) { + // Distinguish br (beads_rust) from bd (beads) by checking if `br` is available + if (await isBrAvailable()) return "beads_rust"; + return "beads"; + } if (await dirExists(join(cwd, ".seeds"))) return "seeds"; // Default fallback — seeds is the preferred tracker return "seeds"; } +/** + * Check if the `br` (beads_rust) CLI is available on PATH. + */ +async function isBrAvailable(): Promise { + try { + const proc = Bun.spawn(["br", "version"], { stdout: "pipe", stderr: "pipe" }); + const exitCode = await proc.exited; + return exitCode === 0; + } catch { + return false; + } +} + /** * Return the CLI tool name for a resolved backend. */ export function trackerCliName(backend: TrackerBackend): string { - return backend === "seeds" ? "sd" : "bd"; + switch (backend) { + case "beads": + return "bd"; + case "beads_rust": + return "br"; + case "seeds": + return "sd"; + } } // Re-export types for convenience diff --git a/src/tracker/types.ts b/src/tracker/types.ts index ad047f8d..84b3296a 100644 --- a/src/tracker/types.ts +++ b/src/tracker/types.ts @@ -49,4 +49,4 @@ export interface TrackerClient { } /** Which tracker backend to use. */ -export type TrackerBackend = "beads" | "seeds"; +export type TrackerBackend = "beads" | "beads_rust" | "seeds"; diff --git a/src/types.ts b/src/types.ts index 9f3e7eaa..e4ebf0c2 100644 --- a/src/types.ts +++ b/src/types.ts @@ -35,7 +35,7 @@ export interface PiRuntimeConfig { // === Task Tracker === /** Backend for the task tracker. Defined here for use in OverstoryConfig. */ -export type TaskTrackerBackend = "auto" | "seeds" | "beads"; +export type TaskTrackerBackend = "auto" | "seeds" | "beads" | "beads_rust"; // === Project Configuration === @@ -84,7 +84,7 @@ export interface OverstoryConfig { baseDir: string; // Where worktrees live }; taskTracker: { - backend: TaskTrackerBackend; // "auto" | "seeds" | "beads" + backend: TaskTrackerBackend; // "auto" | "seeds" | "beads" | "beads_rust" enabled: boolean; }; mulch: {