Skip to content
This repository was archived by the owner on May 28, 2026. It is now read-only.
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
227 changes: 227 additions & 0 deletions src/beads-rust/client.ts
Original file line number Diff line number Diff line change
@@ -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<BeadsRustIssue[]>;

/** Show details for a specific issue. */
show(id: string): Promise<BeadsRustIssue>;

/** Create a new issue. Returns the new issue ID. */
create(
title: string,
options?: { type?: string; priority?: number; description?: string },
): Promise<string>;

/** Claim an issue (atomic: assignee=actor + status=in_progress). */
claim(id: string): Promise<void>;

/** Close an issue with an optional reason. */
close(id: string, reason?: string): Promise<void>;

/** List issues with optional filters. */
list(options?: { status?: string; limit?: number }): Promise<BeadsRustIssue[]>;

/** Sync tracker state (export DB to JSONL). */
sync(): Promise<void>;
}

/**
* 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<T>(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<RawBrIssue[]>(stdout, "ready");
return raw.map(normalizeIssue);
},

async show(id) {
const { stdout } = await runBr(["show", id, "--json"], `show ${id}`);
const raw = parseJsonOutput<RawBrIssue[]>(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<BrCreateResponse>(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<BrListEnvelope>(stdout, "list");
return envelope.issues.map(normalizeIssue);
},

async sync() {
await runBr(["sync", "--flush-only"], "sync");
},
};
}
12 changes: 11 additions & 1 deletion src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -481,6 +481,16 @@ function migrateDeprecatedTaskTrackerKeys(parsed: Record<string, unknown>): 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<string, unknown>;
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<string, unknown>;
parsed.taskTracker = {
Expand Down Expand Up @@ -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",
Expand Down
50 changes: 50 additions & 0 deletions src/tracker/beads-rust.ts
Original file line number Diff line number Diff line change
@@ -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();
},
};
}
30 changes: 26 additions & 4 deletions src/tracker/factory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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 });
}
Expand All @@ -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");
});
Expand Down
Loading