Skip to content
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
6 changes: 6 additions & 0 deletions .claude-plugin/marketplace.json
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,12 @@
"source": "./plugins/bitwarden-testmo-tools",
"version": "1.0.0",
"description": "Testmo API access for Bitwarden β€” create test runs from reviewable filter specs, one or many per pass, with a dry-run, milestone linkage, and duplicate checks."
},
{
"name": "bitwarden-beads-tools",
"source": "./plugins/bitwarden-beads-tools",
"version": "0.1.0",
"description": "Tools that complement the beads (bd) issue tracker in Claude Code, including a live pane and status line of the project's open, in-progress, and blocked beads."
}
]
}
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ A curated collection of plugins for AI-assisted development at Bitwarden. Enable
| [bitwarden-shepherd](plugins/bitwarden-shepherd/) | 1.0.1 | Champion of a technical strategy β€” shepherds a TSI through evaluation into the funnel, then through to adoption |
| [bitwarden-adr-tools](plugins/bitwarden-adr-tools/) | 1.0.0 | Architecture Decision Record tools: checking designs and plans against the ADR catalog, locating and summarizing ADRs |
| [bitwarden-atlassian-tools](plugins/bitwarden-atlassian-tools/) | 2.8.2 | Atlassian access via MCP server with deep Jira issue research skill and opt-in Jira write tools |
| [bitwarden-beads-tools](plugins/bitwarden-beads-tools/) | 0.1.0 | Tools that complement the beads (`bd`) issue tracker, including a live pane and status line of the project's open, in-progress, and blocked beads |
| [bitwarden-code-review](plugins/bitwarden-code-review/) | 2.7.0 | Autonomous code review agent following Bitwarden engineering standards with GitHub integration |
| [bitwarden-delivery-tools](plugins/bitwarden-delivery-tools/) | 3.5.2 | Delivery lifecycle skills: initiative funnel navigation, work transitions, architectural judgment, feature flags, commits, PRs, preflight, labeling, Jira ticket filing |
| [bitwarden-designer](plugins/bitwarden-designer/) | 0.1.0 | Product designer persona: Code of Conduct and 30/60/90 critique, critique facilitation; dispatches into bitwarden-design-tools |
Expand Down
12 changes: 12 additions & 0 deletions plugins/bitwarden-beads-tools/.claude-plugin/plugin.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"name": "bitwarden-beads-tools",
"version": "0.1.0",
"description": "Tools that complement the beads (bd) issue tracker in Claude Code, including a live pane and status line of the project's open, in-progress, and blocked beads.",
"author": {
"name": "Bitwarden"
},
"homepage": "https://github.com/bitwarden/ai-plugins/tree/main/plugins/bitwarden-beads-tools",
"repository": "https://github.com/bitwarden/ai-plugins",
"keywords": ["beads", "bd", "issue-tracking", "pane", "status-line"],
"types": "./types/index.d.ts"
}
16 changes: 16 additions & 0 deletions plugins/bitwarden-beads-tools/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
# Changelog

All notable changes to the `bitwarden-beads-tools` plugin will be documented in this file.

The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [0.1.0] - 2026-10-21

### Added

- New plugin of tools that complement the beads (`bd`) issue tracker in Claude Code.
- **Beads pane** tool, which keeps the project's beads in view during a session:
- **Pane**: lists open, in-progress, and blocked beads from `bd list --json`, grouped by status and sorted by priority, so the state of the work is visible without running `bd` by hand.
- **Status line**: a one-line count of beads by status, left empty when the project has nothing open.
- **`/beads-pane` command**: refreshes the list and opens the pane on demand.
- Refreshes at the start of an interactive session, every 30 seconds once a beads project is found, after any Bash tool call that runs `bd`, and when the working directory changes; stays hidden outside a beads project.
63 changes: 63 additions & 0 deletions plugins/bitwarden-beads-tools/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
# Bitwarden Beads Tools Plugin

Tools that complement the beads issue tracker in Claude Code.

## Overview

[Beads](https://github.com/steveyegge/beads) is a git-backed issue tracker driven by the `bd` CLI. This plugin collects tools that work alongside `bd` inside a Claude Code session, so the state of the work is visible and current without leaving the session or running `bd` by hand. The tools ship as a hooks module (`hooks/register.tsx`) loaded through Claude Code's function hooks.

| Tool | What It Does |
| ------------------------- | ------------------------------------------------------------------------------------------------------------------ |
| [Beads pane](#beads-pane) | Shows the project's open, in-progress, and blocked beads in a pane and the status line, kept current as `bd` runs. |

## Requirements

- The `bd` CLI from [beads](https://github.com/steveyegge/beads), version 1.3 or later, on `PATH`. The beads pane passes `--brief` to `bd list`, which earlier releases reject, so an older `bd` reads as a directory with no beads project.
- A Claude Code build with function hooks (plugins whose `hooks/hooks.json` lists `modules`).

## Installation

```bash
/plugin install bitwarden-beads-tools@bitwarden-marketplace
```

## Tools

### Beads pane

Reads the current project's open, in-progress, and blocked beads with `bd list --json` and shows them beside the conversation.

The pane refreshes when an interactive session starts, every 30 seconds once it has found a beads project, whenever a Bash tool call runs a `bd` command, and when the session's working directory changes. In a directory that is not a beads project, `bd list` fails and the tool stays out of the way: no pane opens, the status line is left empty, and nothing polls `bd`.

#### What It Shows

| Surface | What It Shows |
| ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| Pane | A **Beads** pane grouped into In progress, Blocked, and Open, each with its count. Every row shows the priority, the bead id, and the title, sorted by priority and then id within each group. |
| Status line | A one-line count such as `beads: 1 in progress, 2 blocked, 4 open`, or `beads: error reading bd list` when its output cannot be parsed. The line stays empty when the project has nothing outstanding. |
| Command | `/beads-pane` refreshes the list and opens the pane, replying with the same count. Outside a beads project it replies that there is nothing to read. |

The pane opens on its own at session start when the directory is a beads project and the terminal is at least 144 columns wide, or 110 once you have opened it yourself. `/beads-pane` opens it at any width. Headless runs (`claude -p` and the SDK) skip the pane and the 30-second poll.

#### Usage

Start Claude Code in a directory that holds a beads project and the pane opens with the session. To bring it back after closing it, or to refresh it on demand:

```
/beads-pane
```

## Development

The plugin's tests run under Claude Code's plugin test runner, and the manifest and hooks module are checked by the validator:

```bash
claude plugin validate plugins/bitwarden-beads-tools
claude plugin test plugins/bitwarden-beads-tools
```

When the plugin is loaded from this folder with `--plugin-dir` in an interactive session, Claude Code writes its API declarations to `.claude-plugin/types/`, which `tsconfig.json` extends so `tsc -p plugins/bitwarden-beads-tools` type-checks the module. That folder is generated on each load and ignores itself.

## Privacy & Security

Everything runs locally. The beads pane runs `bd list` in the session's working directory with a fixed argument list and a 10-second timeout, reads only the id, title, status, priority, and type of each bead, and sends nothing anywhere.
1 change: 1 addition & 0 deletions plugins/bitwarden-beads-tools/hooks/hooks.json
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{ "modules": ["./register.tsx"] }
250 changes: 250 additions & 0 deletions plugins/bitwarden-beads-tools/hooks/register.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,250 @@
import { atom, read, update } from "claude-code";
import type { EngineInterface, Register } from "claude-code";

import type { Bead, BeadsSnapshot, BeadStatus } from "../types";

const PANE = "beads";
const POLL_MS = 30_000;
const STATUSES: readonly BeadStatus[] = ["in_progress", "blocked", "open"];
const LABELS: Record<BeadStatus, string> = {
in_progress: "In progress",
blocked: "Blocked",
open: "Open",
};
const COLORS: Record<BeadStatus, "warning" | "error" | "suggestion"> = {
in_progress: "warning",
blocked: "error",
open: "suggestion",
};

const snapshot = atom(
{ plugin: "bitwarden-beads-tools", key: "snapshot" } as const,
null,
);

// Set when an interactive session starts and cleared once the poll begins, so
// the poll waits for the first refresh that finds a beads project.
let canPoll = false;

// Counts refreshes, so one that a newer refresh overtook leaves the snapshot
// and status line to it.
let generation = 0;

// A null snapshot means the session's directory is not a beads project, so
// the pane and status line stay out of the way. A project with nothing open
// also leaves the status line empty, so the line only appears when there is
// work or an error to report.
async function refresh($: EngineInterface): Promise<BeadsSnapshot | null> {
const mine = ++generation;
const next = await load($);

if (mine !== generation) {
return next;
}

await update($, snapshot, () => next);

const isQuiet =
next === null || (next.error === undefined && next.beads.length === 0);
Comment on lines +47 to +48

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The isQuiet condition combines || and && on one line, requiring readers to track multiple logical sequences.

Show fix
Suggested change
const isQuiet =
next === null || (next.error === undefined && next.beads.length === 0);
const hasNoError = next !== null && next.error === undefined;
const hasNoBeads = next !== null && next.beads.length === 0;
const isQuiet = next === null || (hasNoError && hasNoBeads);
Details

✨ AI Reasoning
​The isQuiet expression combines an OR with a grouped AND on the same line. Readers must track how those conditions interact to determine whether the snapshot is quiet, making this more cognitively demanding than a single logical sequence.

Reply on this comment to give feedback or ignore the issue.
More info

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The parenthesized grouping already reads as one rule: no project, or a project with nothing open and no error. Splitting it repeats the null guard twice without making that clearer.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

β›” You don't have permission to ignore issues.

$.ui.status(isQuiet ? undefined : summary(next));

if (next !== null && canPoll) {
canPoll = false;
$.clock.every(POLL_MS, () => void refresh($));

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ The timer from $.clock.every is never kept, so once a refresh finds a beads project the poll runs for the rest of the session. If the working directory later changes to a folder that isn't a beads project (CwdChanged fires), every 30 seconds still spawns bd list there with a 10 second timeout. The status line clears, so nothing looks wrong, but README.md:30 says that outside a beads project "nothing polls bd". This is the same cost the first round gated, just reached after a directory change instead of at startup.

every returns a timer with cancel(), so the poll can follow the snapshot:

let isInteractive = false;
let poll: { cancel(): void } | undefined;

// in refresh, after the generation check
if (next === null) {
  poll?.cancel();
  poll = undefined;
} else if (isInteractive && poll === undefined) {
  poll = $.clock.every(POLL_MS, () => void refresh($));
}

with isInteractive = true in session.start in place of canPoll. A bd run that fails once would stop the poll until the next trigger (a bd call, a directory change, or /beads-pane), which seems fine. A test that changes directory to a non-beads folder and advances the clock would cover it.

}

return next;
}

async function load($: EngineInterface): Promise<BeadsSnapshot | null> {
const cwd = await $.session.cwd();
const argv = [
"bd",
"list",
"--json",
"--brief",
"--skip-labels",
"-n",
"0",
"--status",
STATUSES.join(","),
];
const ran = await $.process
.run(argv, { cwd, timeoutMs: 10_000 })
.catch(() => null);

if (ran === null || ran.exitCode !== 0) {
Comment thread
SaintPatrck marked this conversation as resolved.
Resolved
return null;
}

try {
const parsed: unknown = JSON.parse(ran.stdout || "{}");
Comment thread
SaintPatrck marked this conversation as resolved.
Resolved
const rows = (parsed as { issues?: unknown } | null)?.issues;

if (!Array.isArray(rows)) {
return { beads: [], error: "bd list returned an unexpected shape" };
}

return { beads: rows.map(toBead).filter(isBead) };
} catch {
return { beads: [], error: "bd list returned output that is not JSON" };
}
}

function toBead(row: unknown): Bead | null {
if (typeof row !== "object" || row === null) {
return null;
}

const { id, title, status, priority, issue_type } = row as Record<
string,
unknown
>;

if (typeof id !== "string" || !STATUSES.includes(status as BeadStatus)) {
return null;
}

return {
id,
title: typeof title === "string" ? title : "",
status: status as BeadStatus,
priority: typeof priority === "number" ? priority : 4,
type: typeof issue_type === "string" ? issue_type : "task",
};
}

function isBead(bead: Bead | null): bead is Bead {
return bead !== null;
}

export function summary({ beads, error }: BeadsSnapshot): string {
if (error !== undefined) {
return "beads: error reading bd list";
}

const counts = STATUSES.map((status) => {
const count = beads.filter((bead) => bead.status === status).length;

return [status, count] as const;
});
const parts = counts
.filter(([, n]) => n > 0)
.map(([status, n]) => `${n} ${LABELS[status].toLowerCase()}`);

return `beads: ${parts.length === 0 ? "none open" : parts.join(", ")}`;
}

function clip(text: string, width: number): string {
return text.length <= width
? text
: text.slice(0, Math.max(0, width - 3)) + "...";
}

export const register: Register = (on) => {
on("session.start", async ($, e, next) => {
await $.command.register({
name: "beads-pane",
description: "Show this project's beads in a pane",
});

if (e.isInteractive) {
canPoll = true;
void refresh($).then((found) => {
if (found !== null) {
void $.ui.open({ id: PANE, title: "Beads" });
}
});
}

return next(e);
});

// /clear, /resume, and /branch reset the snapshot to null without firing
// session.start again, so read the beads again for the reset session.
on(
"classic.SessionStart",
{ source: ["clear", "resume", "fork"] },
async ($, e, next) => {
void refresh($);

return next(e);
},
);

on("command.run", { command: "beads-pane" }, async ($) => {
const found = await refresh($);

if (found === null) {
return { text: "No beads project here (bd list found nothing to read)." };
}

await $.ui.open({ id: PANE, title: "Beads" });

return { text: `Beads pane opened. ${summary(found)}` };
});

on("tool.call", { tool: "Bash" }, async ($, e, next) => {
const ran = await next(e);

if (/(^|[\s;&|(])bd\s/.test(e.command)) {
void refresh($);
}

return ran;
});

on("classic.CwdChanged", async ($, e, next) => {
const ran = await next(e);
void refresh($);

return ran;
});

on("ui.render", { component: "Pane", requestId: PANE }, async ($, e) => {
const { Box, Text } = $.ui.resolve(e);
const current = await read($, snapshot);
const width = Math.max(20, e.props.bodyColumns);

if (current === null) {
return <Text dimColor>No beads project in this directory.</Text>;
}

if (current.error !== undefined) {
return <Text color="error">{current.error}</Text>;
}

if (current.beads.length === 0) {
return <Text dimColor>No open beads.</Text>;
}

const groups = STATUSES.map((status) => ({
status,
beads: current.beads
.filter((bead) => bead.status === status)
.sort((a, b) => a.priority - b.priority || a.id.localeCompare(b.id)),
})).filter((group) => group.beads.length > 0);

return (
<Box flexDirection="column">
{groups.map((group) => (
<Box key={group.status} flexDirection="column" marginBottom={1}>
<Text bold color={COLORS[group.status]}>
{LABELS[group.status]} ({group.beads.length})
</Text>
{group.beads.map((bead) => {
const head = `P${bead.priority} ${bead.id} `;

return (
<Text key={bead.id}>
<Text dimColor>{head}</Text>
{clip(bead.title, width - head.length)}
</Text>
);
})}
</Box>
))}
</Box>
);
});
};
Loading
Loading