From ea03b6e3828e8409e7620140f2d30cea7b860628 Mon Sep 17 00:00:00 2001 From: Erik Aker Date: Wed, 20 May 2026 17:11:55 -0700 Subject: [PATCH 1/3] Adding a pi-code extension to use bmo in pi-code --- .gitignore | 1 + README.md | 24 +- docs/index.md | 1 + docs/pi-extension.md | 112 +++++ pi-extension/bmo-tools.ts | 957 ++++++++++++++++++++++++++++++++++++++ pi-extension/index.ts | 257 ++++++++++ pi-extension/package.json | 20 + 7 files changed, 1371 insertions(+), 1 deletion(-) create mode 100644 docs/pi-extension.md create mode 100644 pi-extension/bmo-tools.ts create mode 100644 pi-extension/index.ts create mode 100644 pi-extension/package.json diff --git a/.gitignore b/.gitignore index c1c8c2a..6dff15f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ /target +pi-extension/node_modules .bmo .docket docs/tdd diff --git a/README.md b/README.md index 773d530..0814c5f 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ For an example of agents and skills that have been instructed to use `bmo` check ## Attribution -`bmo` was inspired by and adapted from [docket](https://github.com/ALT-F4-LLC/docket), an issue tracker for AI agents written by **ALT-F4-LLC**. The design, data model, and command structure of BMO all owe a direct debt to that project, and all credit for the underlying ideas belongs there. +`bmo` was inspired by and adapted from [docket](https://github.com/ALT-F4-LLC/docket), an issue tracker for AI agents written by **ALT-F4-LLC**. In addition, the code in this repository was written by [Claude Code](https://claude.ai/claude-code), Anthropic's AI coding assistant. The repo owner directed this work. @@ -44,10 +44,32 @@ bmo board bmo web ``` +## pi-code Integration + +If you use [pi-code](https://pi.dev), the `pi-bmo` extension integrates bmo natively +into pi. This extension adds typed `bmo_*` tools that the LLM calls directly, in addition to a live board status widget, board +state injected into every agent turn's context, and `/board` `/next` `/plan` slash commands. + +```bash +pi install git:github.com/erewok/bmo +``` + +A companion pi-code extension, [bmo-dev-team](https://github.com/erewok/bmo-agent-setup), adds a +full AI software development team (staff-engineer, project-manager, senior-engineer, +qa-engineer, ux-designer) that plans and executes work through bmo. Installing it also +pulls in `pi-bmo` automatically: + +```bash +pi install git:github.com/erewok/bmo-agent-setup +``` + +See [docs/pi-extension.md](docs/pi-extension.md) for the full integration guide. + ## Documentation - [docs/commands.md](docs/commands.md) — Complete command reference - [docs/agents.md](docs/agents.md) — Integration guide for AI agents +- [docs/pi-extension.md](docs/pi-extension.md) — pi-code extension and dev-team suite - [docs/web.md](docs/web.md) — Web interface - [docs/data.md](docs/data.md) — Data storage and database location - [docs/migration-from-docket.md](docs/migration-from-docket.md) — Migrating from docket diff --git a/docs/index.md b/docs/index.md index e4fa5b2..fbf6f07 100644 --- a/docs/index.md +++ b/docs/index.md @@ -4,6 +4,7 @@ |------|-------------| | [commands.md](commands.md) | Complete command reference — all commands, flags, and JSON output shapes | | [agents.md](agents.md) | Integration guide for AI agents — session setup, finding work, tracking progress | +| [pi-extension.md](pi-extension.md) | pi-code extension — native bmo tools, context injection, and the dev-team agent suite | | [web.md](web.md) | Web interface — starting the server, flags, live updates | | [data.md](data.md) | Data storage — SQLite location, directory walk behavior, `BMO_DB` env var | | [migration-from-docket.md](migration-from-docket.md) | Migrating from docket to bmo | diff --git a/docs/pi-extension.md b/docs/pi-extension.md new file mode 100644 index 0000000..f7fd5a3 --- /dev/null +++ b/docs/pi-extension.md @@ -0,0 +1,112 @@ +# bmo + pi-code Integration + +[pi-code](https://pi.dev) is an AI coding agent that runs in the terminal. The `pi-bmo` +extension integrates bmo natively into pi, replacing raw shell commands with typed tools +the LLM can call directly and adding board-state awareness to every session. + +## What the Extension Provides + +| Feature | Description | +|---------|-------------| +| **`bmo_*` tools** | Typed wrappers for every bmo command — the LLM calls structured tools instead of constructing shell strings | +| **Context injection** | On every agent turn, the current board summary and next work-ready issues are silently injected into context — no extra tool call needed | +| **Status widget** | Compact board counts (`○3 ●2 ◐1 ◎0 ✔5`) displayed above the pi editor, updated after every turn | +| **Slash commands** | `/bmo-board`, `/bmo-ls`, `/bmo-show`, `/bmo-next`, `/bmo-plan`, `/bmo-stats`, `/bmo-init`, `/bmo-widget` | + +### Available Tools + +| Tool | Equivalent bmo command | +|------|------------------------| +| `bmo_init` | `bmo init` | +| `bmo_agent_init` | `bmo agent-init` | +| `bmo_board` | `bmo board --json` | +| `bmo_plan` | `bmo plan --json [--phase N]` | +| `bmo_next` | `bmo next --json` | +| `bmo_show` | `bmo show --json` | +| `bmo_list` | `bmo list --json [--status] [--assignee]` | +| `bmo_create` | `bmo create -t ...` | +| `bmo_edit` | `bmo edit ...` | +| `bmo_move` | `bmo move -s ...` | +| `bmo_claim` | `bmo claim --assignee ...` | +| `bmo_close` | `bmo close ` | +| `bmo_reopen` | `bmo reopen ` | +| `bmo_comment` | `bmo comment add/list` | +| `bmo_link` | `bmo link add ` | +| `bmo_file` | `bmo file add ` | +| `bmo_stats` | `bmo stats` | + +The extension discovers the bmo project the same way the CLI does — by walking up from the +current working directory looking for `.bmo/issues.db`. No configuration needed. + +## Installation + +Install the extension into pi's global configuration: + +```bash +pi install git:github.com/erewok/bmo +``` + +pi clones the repo, runs `npm install`, and loads the extension from `pi-extension/` +automatically on next start. + +To try it without making it permanent: + +```bash +pi -e git:github.com/erewok/bmo +``` + +### Local Development + +If you have the bmo repo checked out locally, add the extension path to +`~/.pi/agent/settings.json` instead: + +```json +{ + "extensions": ["/path/to/bmo/pi-extension"] +} +``` + +## Dev-Team Extension + +A companion extension, [bmo-dev-team](https://github.com/erewok/bmo-agent-setup), +builds on `pi-bmo` to provide a full AI software development team that plans and +executes work through bmo: + +- **`staff-engineer`** — produces Technical Design Documents and reviews all implementation changes +- **`project-manager`** — decomposes requests into bmo issues with dependency graphs and file scoping +- **`senior-engineer`** — implements from bmo issues within their scoped files +- **`qa-engineer`** — verifies acceptance criteria and posts results as bmo comments +- **`ux-designer`** — produces UX design specs before technical planning +- **`dev-team` skill** — orchestration skill that sequences the team: design → plan → implement → review → verify + +Installing `bmo-dev-team` also pulls in `pi-bmo` automatically — you do not need to +install both separately: + +```bash +pi install git:github.com/erewok/bmo-agent-setup +``` + +### How the dev-team uses bmo + +The dev-team workflow is built around bmo's dependency graph and parallel execution +model. A typical Medium-complexity feature looks like this: + +1. **Staff engineer** explores the codebase and writes a TDD in `docs/tdd/` +2. **Project manager** decomposes the TDD into bmo issues with phases, dependencies, + and file attachments — calling `bmo_create`, `bmo_link`, and `bmo_file` for each +3. **Senior engineers** run in parallel within each phase, each claiming their issue + with `bmo_claim` and moving it to `review` with `bmo_move` on completion +4. **Staff engineer** reviews all changes and posts structured feedback as `bmo_comment` + calls — blockers prevent the issue from closing +5. **QA engineer** verifies acceptance criteria and posts results via `bmo_comment` +6. **Orchestrator** closes each issue with `bmo_close` once review passes + +Because bmo file attachments scope each issue to specific files, the orchestrator can +detect collisions before spawning parallel agents and serialize any conflicting work. + +## Requirements + +- [bmo](https://github.com/erewok/bmo) installed and on `$PATH` +- [pi-code](https://pi.dev) installed + +The extension has no additional runtime dependencies. diff --git a/pi-extension/bmo-tools.ts b/pi-extension/bmo-tools.ts new file mode 100644 index 0000000..0a42bfe --- /dev/null +++ b/pi-extension/bmo-tools.ts @@ -0,0 +1,957 @@ +/** + * Native bmo tools for pi-code. + * + * Each tool wraps a bmo CLI command with typed parameters and structured + * JSON output, so the LLM never needs to remember raw CLI flags. + * + * All tools run `bmo` with cwd set to ctx.cwd, letting bmo auto-discover + * .bmo/issues.db the same way git discovers .git — by walking up from cwd. + */ + +import { execFile } from "node:child_process"; +import * as path from "node:path"; +import * as fs from "node:fs"; +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; +import { StringEnum } from "@earendil-works/pi-ai"; +import { Container, Spacer, Text } from "@earendil-works/pi-tui"; +import { Type } from "typebox"; + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +function runBmo( + args: string[], + cwd: string, + signal?: AbortSignal, +): Promise<{ stdout: string; stderr: string; exitCode: number }> { + return new Promise((resolve) => { + const proc = execFile("bmo", [...args, "--json"], { cwd, shell: false }, (err, stdout, stderr) => { + resolve({ stdout, stderr, exitCode: (err as any)?.code ?? 0 }); + }); + if (signal) { + const kill = () => proc.kill("SIGTERM"); + if (signal.aborted) kill(); + else signal.addEventListener("abort", kill, { once: true }); + } + }); +} + +function toContent(stdout: string, stderr: string, exitCode: number): { type: "text"; text: string }[] { + if (stdout.trim()) { + try { + return [{ type: "text", text: JSON.stringify(JSON.parse(stdout.trim()), null, 2) }]; + } catch { + return [{ type: "text", text: stdout.trim() }]; + } + } + if (stderr.trim()) return [{ type: "text", text: `Error: ${stderr.trim()}` }]; + return [{ type: "text", text: exitCode === 0 ? "(success)" : `(exit code ${exitCode})` }]; +} + +/** Parse the JSON response from a tool result's content text. Returns null on failure. */ +function parseResult(result: { content: { type: string; text?: string }[] }): any | null { + const block = result.content[0]; + if (!block || block.type !== "text" || !block.text) return null; + try { return JSON.parse(block.text); } catch { return null; } +} + +/** Fallback: return the raw text from a tool result. */ +function rawText(result: { content: { type: string; text?: string }[] }): string { + const block = result.content[0]; + return (block?.type === "text" && block.text) ? block.text : "(no output)"; +} + +const STATUS_COLOR: Record = { + backlog: "muted", + todo: "accent", + "in-progress": "warning", + "in_progress": "warning", + review: "text", + done: "success", +}; + +const PRIORITY_COLOR: Record = { + critical: "error", + high: "warning", + medium: "accent", + low: "muted", + none: "dim", +}; + +// --------------------------------------------------------------------------- +// Exported helpers (used by index.ts for widget and context injection) +// --------------------------------------------------------------------------- + +/** Walk up from cwd looking for .bmo/issues.db — same discovery logic as bmo itself. */ +export function hasBmoProject(cwd: string): boolean { + let dir = cwd; + while (true) { + if (fs.existsSync(path.join(dir, ".bmo", "issues.db"))) return true; + const parent = path.dirname(dir); + if (parent === dir) return false; + dir = parent; + } +} + +/** Return board counts by status column, or null if no bmo project or on error. */ +export async function getBoardCounts(cwd: string): Promise | null> { + if (!hasBmoProject(cwd)) return null; + const { stdout } = await runBmo(["board"], cwd); + try { + const data = JSON.parse(stdout.trim()); + if (!data.ok) return null; + const board = data.data as Record; + return { + backlog: board.backlog?.length ?? 0, + todo: board.todo?.length ?? 0, + in_progress: board.in_progress?.length ?? 0, + review: board.review?.length ?? 0, + done: board.done?.length ?? 0, + }; + } catch { + return null; + } +} + +/** Return a compact summary of next work-ready issues, or null if none / no project. */ +export async function getNextSummary(cwd: string): Promise { + if (!hasBmoProject(cwd)) return null; + const { stdout } = await runBmo(["next"], cwd); + try { + const data = JSON.parse(stdout.trim()); + if (!data.ok || !Array.isArray(data.data) || data.data.length === 0) return null; + return (data.data as Array<{ id: number; title: string; status: string }>) + .map((i) => `BMO-${i.id}: ${i.title} [${i.status}]`) + .join("\n"); + } catch { + return null; + } +} + +// --------------------------------------------------------------------------- +// Tool registration +// --------------------------------------------------------------------------- + +export function registerBmoTools(pi: ExtensionAPI): void { + + // ── bmo_init ────────────────────────────────────────────────────────────── + pi.registerTool({ + name: "bmo_init", + label: "bmo init", + description: + "Initialize a bmo issue-tracking project in the current directory. " + + "Creates .bmo/issues.db. Safe to call again — shows current status if already initialized.", + promptSnippet: "Initialize or check bmo project in the current directory", + parameters: Type.Object({}), + async execute(_id, _p, signal, _u, ctx) { + const { stdout, stderr, exitCode } = await new Promise<{ stdout: string; stderr: string; exitCode: number }>( + (resolve) => { + const proc = execFile("bmo", ["init"], { cwd: ctx.cwd, shell: false }, (err, stdout, stderr) => { + resolve({ stdout, stderr, exitCode: (err as any)?.code ?? 0 }); + }); + if (signal) { + const kill = () => proc.kill("SIGTERM"); + if (signal.aborted) kill(); + else signal.addEventListener("abort", kill, { once: true }); + } + }, + ); + const text = stdout.trim() || stderr.trim() || (exitCode === 0 ? "bmo initialized." : `Exit ${exitCode}`); + return { content: [{ type: "text", text }], details: {} }; + }, + renderResult(result, _opts, theme) { + const text = rawText(result); + const icon = result.isError ? theme.fg("error", "✗ ") : theme.fg("success", "✓ "); + return new Text(icon + text, 0, 0); + }, + }); + + // ── bmo_agent_init ──────────────────────────────────────────────────────── + pi.registerTool({ + name: "bmo_agent_init", + label: "bmo agent-init", + description: + "One-shot session orientation: initializes bmo if needed, then prints board, " + + "next work-ready issues, stats, and a command cheat sheet. " + + "Call this at the start of every agent session that touches bmo.", + promptSnippet: "Orient to the current bmo project (board + next + stats + cheat sheet)", + promptGuidelines: [ + "Use bmo_agent_init at the start of every agent session before any planning or implementation.", + ], + parameters: Type.Object({}), + async execute(_id, _p, signal, _u, ctx) { + const { stdout, stderr } = await new Promise<{ stdout: string; stderr: string }>((resolve) => { + const proc = execFile("bmo", ["agent-init"], { cwd: ctx.cwd, shell: false }, (_err, stdout, stderr) => { + resolve({ stdout, stderr }); + }); + if (signal) { + const kill = () => proc.kill("SIGTERM"); + if (signal.aborted) kill(); + else signal.addEventListener("abort", kill, { once: true }); + } + }); + return { content: [{ type: "text", text: stdout.trim() || stderr.trim() || "(no output)" }], details: {} }; + }, + }); + + // ── bmo_board ───────────────────────────────────────────────────────────── + pi.registerTool({ + name: "bmo_board", + label: "bmo board", + description: "Return the full kanban board organized by status column (backlog / todo / in-progress / review / done).", + promptSnippet: "Get the full bmo kanban board as structured JSON", + parameters: Type.Object({}), + async execute(_id, _p, signal, _u, ctx) { + const { stdout, stderr, exitCode } = await runBmo(["board"], ctx.cwd, signal); + return { content: toContent(stdout, stderr, exitCode), details: {} }; + }, + renderCall(_args, theme) { + return new Text(theme.fg("toolTitle", theme.bold("bmo board")), 0, 0); + }, + renderResult(result, _opts, theme) { + const data = parseResult(result); + if (!data) return new Text(theme.fg("error", rawText(result)), 0, 0); + if (!data.ok) return new Text(theme.fg("error", data.error ?? rawText(result)), 0, 0); + + const board = data.data as Record>; + const columns = [ + { key: "backlog", label: "Backlog", color: "muted" }, + { key: "todo", label: "Todo", color: "accent" }, + { key: "in_progress", label: "In Progress", color: "warning" }, + { key: "review", label: "Review", color: "text" }, + { key: "done", label: "Done", color: "success" }, + ] as const; + + const total = columns.reduce((n, c) => n + (board[c.key]?.length ?? 0), 0); + if (total === 0) { + return new Text(theme.fg("dim", "Board is empty — no issues yet."), 0, 0); + } + + const container = new Container(); + for (const col of columns) { + const issues = board[col.key] ?? []; + if (issues.length === 0) continue; + + const header = theme.fg(col.color, `${col.label.toUpperCase()} (${issues.length})`); + container.addChild(new Text(header, 0, 0)); + for (const issue of issues.slice(0, 8)) { + const priorityMark = issue.priority === "critical" ? theme.fg("error", "!") : + issue.priority === "high" ? theme.fg("warning", "↑") : " "; + container.addChild(new Text( + ` ${priorityMark} ${theme.fg("dim", `BMO-${issue.id}`)} ${issue.title}`, + 0, 0, + )); + } + if (issues.length > 8) { + container.addChild(new Text(theme.fg("muted", ` … ${issues.length - 8} more`), 0, 0)); + } + container.addChild(new Spacer(1)); + } + return container; + }, + }); + + // ── bmo_plan ────────────────────────────────────────────────────────────── + pi.registerTool({ + name: "bmo_plan", + label: "bmo plan", + description: + "Return the phased execution plan derived from the issue dependency graph. " + + "Issues in the same phase can run in parallel. " + + "Filter to a specific phase number with the optional `phase` parameter.", + promptSnippet: "Get the phased bmo execution plan (all phases, or a specific phase)", + parameters: Type.Object({ + phase: Type.Optional(Type.Number({ description: "Show only this phase number (1-indexed)" })), + }), + async execute(_id, params, signal, _u, ctx) { + const args = ["plan"]; + if (params.phase != null) args.push("--phase", String(params.phase)); + const { stdout, stderr, exitCode } = await runBmo(args, ctx.cwd, signal); + return { content: toContent(stdout, stderr, exitCode), details: {} }; + }, + renderCall(args, theme) { + const suffix = args.phase != null ? theme.fg("dim", ` phase ${args.phase}`) : ""; + return new Text(theme.fg("toolTitle", theme.bold("bmo plan")) + suffix, 0, 0); + }, + renderResult(result, _opts, theme) { + const data = parseResult(result); + if (!data) return new Text(theme.fg("error", rawText(result)), 0, 0); + if (!data.ok) return new Text(theme.fg("error", data.error ?? rawText(result)), 0, 0); + + const { phases, total_phases } = data.data as { + phases: Array<{ number: number; issues: Array<{ id: number; title: string }> }>; + total_phases: number; + }; + + if (total_phases === 0) return new Text(theme.fg("dim", "No phases — board is empty."), 0, 0); + + const container = new Container(); + for (const phase of phases) { + container.addChild(new Text( + theme.fg("accent", `Phase ${phase.number}`) + + theme.fg("dim", ` (${phase.issues.length} issue${phase.issues.length !== 1 ? "s" : ""}, can run in parallel)`), + 0, 0, + )); + for (const issue of phase.issues) { + container.addChild(new Text( + ` ${theme.fg("dim", `BMO-${issue.id}`)} ${issue.title}`, + 0, 0, + )); + } + container.addChild(new Spacer(1)); + } + return container; + }, + }); + + // ── bmo_next ────────────────────────────────────────────────────────────── + pi.registerTool({ + name: "bmo_next", + label: "bmo next", + description: "Return next work-ready issues: status todo or backlog with all blocking issues resolved.", + promptSnippet: "Get next work-ready bmo issues (all blockers resolved)", + parameters: Type.Object({}), + async execute(_id, _p, signal, _u, ctx) { + const { stdout, stderr, exitCode } = await runBmo(["next"], ctx.cwd, signal); + return { content: toContent(stdout, stderr, exitCode), details: {} }; + }, + renderCall(_args, theme) { + return new Text(theme.fg("toolTitle", theme.bold("bmo next")), 0, 0); + }, + renderResult(result, _opts, theme) { + const data = parseResult(result); + if (!data) return new Text(theme.fg("error", rawText(result)), 0, 0); + if (!data.ok) return new Text(theme.fg("error", data.error ?? rawText(result)), 0, 0); + + const issues = data.data as Array<{ id: number; title: string; priority: string; status: string }>; + if (issues.length === 0) return new Text(theme.fg("dim", "No work-ready issues."), 0, 0); + + const container = new Container(); + container.addChild(new Text(theme.fg("muted", `${issues.length} work-ready:`), 0, 0)); + for (const issue of issues) { + const pColor = PRIORITY_COLOR[issue.priority] ?? "dim"; + container.addChild(new Text( + ` ${theme.fg("dim", `BMO-${issue.id}`)} ${issue.title} ${theme.fg(pColor, issue.priority)}`, + 0, 0, + )); + } + return container; + }, + }); + + // ── bmo_show ────────────────────────────────────────────────────────────── + pi.registerTool({ + name: "bmo_show", + label: "bmo show", + description: + "Return full details for an issue: title, description, status, priority, assignee, " + + "comments, relations (blocks / blocked-by), sub-issues, and attached files.", + promptSnippet: "Get full details for a bmo issue including comments and relations", + parameters: Type.Object({ + id: Type.Union([Type.String(), Type.Number()], { + description: "Issue ID — numeric (1) or BMO-prefixed (BMO-1)", + }), + }), + async execute(_id, params, signal, _u, ctx) { + const { stdout, stderr, exitCode } = await runBmo(["show", String(params.id)], ctx.cwd, signal); + return { content: toContent(stdout, stderr, exitCode), details: {} }; + }, + renderCall(args, theme) { + return new Text( + theme.fg("toolTitle", theme.bold("bmo show")) + " " + theme.fg("accent", String(args.id)), + 0, 0, + ); + }, + renderResult(result, _opts, theme) { + const data = parseResult(result); + if (!data) return new Text(theme.fg("error", rawText(result)), 0, 0); + if (!data.ok) return new Text(theme.fg("error", data.error ?? rawText(result)), 0, 0); + + const { issue, comments = [], relations = [], sub_issues = [] } = data.data as { + issue: { + id: number; title: string; status: string; priority: string; + assignee: string | null; description: string; files: string[]; + }; + comments: Array<{ author: string; body: string; created_at: string }>; + relations: Array<{ from_id: number; to_id: number; kind: string }>; + sub_issues: Array<{ id: number; title: string; status: string }>; + }; + + const sColor = STATUS_COLOR[issue.status] ?? "muted"; + const pColor = PRIORITY_COLOR[issue.priority] ?? "dim"; + const container = new Container(); + + // Title + ID + container.addChild(new Text( + theme.fg("accent", theme.bold(`BMO-${issue.id}`)) + " " + theme.bold(issue.title), + 0, 0, + )); + + // Status / priority / assignee + const meta = [ + theme.fg(sColor, issue.status), + theme.fg(pColor, issue.priority), + issue.assignee ? theme.fg("dim", `→ ${issue.assignee}`) : "", + ].filter(Boolean).join(" "); + container.addChild(new Text(meta, 0, 0)); + + // Description + if (issue.description?.trim()) { + container.addChild(new Spacer(1)); + const lines = issue.description.trim().split("\n").slice(0, 6); + for (const line of lines) container.addChild(new Text(theme.fg("text", line), 0, 0)); + if (issue.description.split("\n").length > 6) { + container.addChild(new Text(theme.fg("muted", " …"), 0, 0)); + } + } + + // Files + if (issue.files?.length) { + container.addChild(new Spacer(1)); + container.addChild(new Text(theme.fg("muted", "files:"), 0, 0)); + for (const f of issue.files) { + container.addChild(new Text(theme.fg("dim", ` ${f}`), 0, 0)); + } + } + + // Relations + if (relations.length) { + container.addChild(new Spacer(1)); + for (const rel of relations) { + const other = rel.from_id === issue.id ? rel.to_id : rel.from_id; + container.addChild(new Text( + theme.fg("muted", rel.kind) + " " + theme.fg("dim", `BMO-${other}`), + 0, 0, + )); + } + } + + // Sub-issues + if (sub_issues.length) { + container.addChild(new Spacer(1)); + container.addChild(new Text(theme.fg("muted", `${sub_issues.length} sub-issue${sub_issues.length !== 1 ? "s" : ""}:`), 0, 0)); + for (const sub of sub_issues) { + const sCol = STATUS_COLOR[sub.status] ?? "muted"; + container.addChild(new Text( + ` ${theme.fg("dim", `BMO-${sub.id}`)} ${sub.title} ${theme.fg(sCol, sub.status)}`, + 0, 0, + )); + } + } + + // Comments + if (comments.length) { + container.addChild(new Spacer(1)); + container.addChild(new Text(theme.fg("muted", `${comments.length} comment${comments.length !== 1 ? "s" : ""}:`), 0, 0)); + for (const c of comments.slice(-5)) { + const preview = c.body.length > 100 ? c.body.slice(0, 100) + "…" : c.body; + container.addChild(new Text( + ` ${theme.fg("accent", c.author)} ${theme.fg("text", preview)}`, + 0, 0, + )); + } + if (comments.length > 5) { + container.addChild(new Text(theme.fg("dim", ` … ${comments.length - 5} earlier`), 0, 0)); + } + } + + return container; + }, + }); + + // ── bmo_list ────────────────────────────────────────────────────────────── + pi.registerTool({ + name: "bmo_list", + label: "bmo list", + description: "List issues with optional filters.", + promptSnippet: "List bmo issues (optional status / assignee filter)", + parameters: Type.Object({ + status: Type.Optional( + StringEnum(["backlog", "todo", "in-progress", "review", "done"] as const, { + description: "Filter by status", + }), + ), + assignee: Type.Optional(Type.String({ description: "Filter by assignee name" })), + }), + async execute(_id, params, signal, _u, ctx) { + const args = ["list"]; + if (params.status) args.push("--status", params.status); + if (params.assignee) args.push("--assignee", params.assignee); + const { stdout, stderr, exitCode } = await runBmo(args, ctx.cwd, signal); + return { content: toContent(stdout, stderr, exitCode), details: {} }; + }, + renderCall(args, theme) { + const filters = [ + args.status ? theme.fg("accent", String(args.status)) : "", + args.assignee ? theme.fg("dim", `@${args.assignee}`) : "", + ].filter(Boolean).join(" "); + return new Text( + theme.fg("toolTitle", theme.bold("bmo list")) + (filters ? " " + filters : ""), + 0, 0, + ); + }, + renderResult(result, _opts, theme) { + const data = parseResult(result); + if (!data) return new Text(theme.fg("error", rawText(result)), 0, 0); + if (!data.ok) return new Text(theme.fg("error", data.error ?? rawText(result)), 0, 0); + + const issues = data.data as Array<{ + id: number; title: string; status: string; priority: string; assignee: string | null; + }>; + if (issues.length === 0) return new Text(theme.fg("dim", "No issues found."), 0, 0); + + const container = new Container(); + container.addChild(new Text(theme.fg("muted", `${issues.length} issue${issues.length !== 1 ? "s" : ""}:`), 0, 0)); + for (const issue of issues) { + const sColor = STATUS_COLOR[issue.status] ?? "muted"; + const pColor = PRIORITY_COLOR[issue.priority] ?? "dim"; + const assignee = issue.assignee ? theme.fg("dim", ` → ${issue.assignee}`) : ""; + container.addChild(new Text( + ` ${theme.fg("dim", `BMO-${issue.id}`)} ${issue.title} ` + + `${theme.fg(sColor, issue.status)} ${theme.fg(pColor, issue.priority)}${assignee}`, + 0, 0, + )); + } + return container; + }, + }); + + // ── bmo_create ──────────────────────────────────────────────────────────── + pi.registerTool({ + name: "bmo_create", + label: "bmo create", + description: + "Create a new bmo issue. Call bmo_file immediately after to attach all files scoped to this issue.", + promptSnippet: "Create a new bmo issue", + promptGuidelines: [ + "After bmo_create, immediately call bmo_file for every file scoped to the new issue.", + ], + parameters: Type.Object({ + title: Type.String({ description: "Issue title" }), + description: Type.Optional(Type.String({ description: "Issue body" })), + parent: Type.Optional( + Type.Union([Type.String(), Type.Number()], { description: "Parent issue ID" }), + ), + priority: Type.Optional( + StringEnum(["critical", "high", "medium", "low", "none"] as const, { + description: "Priority (default: medium)", + }), + ), + kind: Type.Optional( + StringEnum(["task", "bug", "feature", "chore"] as const, { + description: "Issue kind (default: task)", + }), + ), + }), + async execute(_id, params, signal, _u, ctx) { + const args = ["create", "-t", params.title]; + if (params.description) args.push("-d", params.description); + if (params.parent != null) args.push("--parent", String(params.parent)); + if (params.priority) args.push("-p", params.priority); + if (params.kind) args.push("-k", params.kind); + const { stdout, stderr, exitCode } = await runBmo(args, ctx.cwd, signal); + return { content: toContent(stdout, stderr, exitCode), details: {} }; + }, + renderCall(args, theme) { + const title = String(args.title ?? ""); + const preview = title.length > 50 ? title.slice(0, 50) + "…" : title; + const suffix = args.priority && args.priority !== "medium" + ? " " + theme.fg(PRIORITY_COLOR[String(args.priority)] ?? "dim", String(args.priority)) + : ""; + return new Text( + theme.fg("toolTitle", theme.bold("bmo create")) + " " + theme.fg("text", preview) + suffix, + 0, 0, + ); + }, + renderResult(result, _opts, theme) { + if (result.isError) return new Text(theme.fg("error", "✗ ") + rawText(result), 0, 0); + // bmo create returns plain text like "Created BMO-7: title" + return new Text(theme.fg("success", "✓ ") + rawText(result), 0, 0); + }, + }); + + // ── bmo_edit ────────────────────────────────────────────────────────────── + pi.registerTool({ + name: "bmo_edit", + label: "bmo edit", + description: "Edit one or more fields on an existing issue. Only provided fields are updated.", + promptSnippet: "Edit bmo issue fields (title, description, status, priority, assignee, parent)", + parameters: Type.Object({ + id: Type.Union([Type.String(), Type.Number()], { description: "Issue ID" }), + title: Type.Optional(Type.String()), + description: Type.Optional(Type.String()), + status: Type.Optional( + StringEnum(["backlog", "todo", "in-progress", "review", "done"] as const), + ), + priority: Type.Optional( + StringEnum(["critical", "high", "medium", "low", "none"] as const), + ), + assignee: Type.Optional(Type.String({ description: "Pass empty string to clear assignee" })), + parent: Type.Optional(Type.Union([Type.String(), Type.Number()])), + }), + async execute(_id, params, signal, _u, ctx) { + const args = ["edit", String(params.id)]; + if (params.title) args.push("-t", params.title); + if (params.description) args.push("-d", params.description); + if (params.status) args.push("-s", params.status); + if (params.priority) args.push("-p", params.priority); + if (params.assignee !== undefined) args.push("-a", params.assignee); + if (params.parent != null) args.push("--parent", String(params.parent)); + const { stdout, stderr, exitCode } = await runBmo(args, ctx.cwd, signal); + return { content: toContent(stdout, stderr, exitCode), details: {} }; + }, + renderCall(args, theme) { + const changes: string[] = []; + if (args.status) changes.push(theme.fg(STATUS_COLOR[String(args.status)] ?? "muted", String(args.status))); + if (args.priority) changes.push(theme.fg(PRIORITY_COLOR[String(args.priority)] ?? "dim", String(args.priority))); + if (args.assignee !== undefined) changes.push(theme.fg("dim", args.assignee ? `→ ${args.assignee}` : "unassigned")); + if (args.title) changes.push(theme.fg("text", `title: "${String(args.title).slice(0, 30)}"`)); + return new Text( + theme.fg("toolTitle", theme.bold("bmo edit")) + " " + + theme.fg("accent", `BMO-${args.id}`) + + (changes.length ? " " + changes.join(" ") : ""), + 0, 0, + ); + }, + }); + + // ── bmo_move ────────────────────────────────────────────────────────────── + pi.registerTool({ + name: "bmo_move", + label: "bmo move", + description: "Change the status of an issue.", + promptSnippet: "Move a bmo issue to a new status", + parameters: Type.Object({ + id: Type.Union([Type.String(), Type.Number()], { description: "Issue ID" }), + status: StringEnum(["backlog", "todo", "in-progress", "review", "done"] as const, { + description: "Target status", + }), + }), + async execute(_id, params, signal, _u, ctx) { + const { stdout, stderr, exitCode } = await runBmo( + ["move", String(params.id), "-s", params.status], + ctx.cwd, signal, + ); + return { content: toContent(stdout, stderr, exitCode), details: {} }; + }, + renderCall(args, theme) { + const sColor = STATUS_COLOR[String(args.status)] ?? "muted"; + return new Text( + theme.fg("toolTitle", theme.bold("bmo move")) + " " + + theme.fg("accent", `BMO-${args.id}`) + " → " + + theme.fg(sColor, String(args.status)), + 0, 0, + ); + }, + renderResult(result, _opts, theme) { + if (result.isError) return new Text(theme.fg("error", "✗ ") + rawText(result), 0, 0); + return new Text(theme.fg("success", "✓ ") + rawText(result), 0, 0); + }, + }); + + // ── bmo_claim ───────────────────────────────────────────────────────────── + pi.registerTool({ + name: "bmo_claim", + label: "bmo claim", + description: + "Atomically claim an issue: sets status to 'in-progress' and records the assignee. " + + "Call this before starting work on any issue to prevent duplicate effort.", + promptSnippet: "Atomically claim a bmo issue (sets in-progress + assignee)", + promptGuidelines: [ + "Use bmo_claim before starting implementation work. Set assignee to a unique agent reference like 'se-BMO-3-1716000000'.", + ], + parameters: Type.Object({ + id: Type.Union([Type.String(), Type.Number()], { description: "Issue ID" }), + assignee: Type.String({ + description: "Unique agent reference, e.g. 'se-BMO-3-1716000000'", + }), + }), + async execute(_id, params, signal, _u, ctx) { + const { stdout, stderr, exitCode } = await runBmo( + ["claim", String(params.id), "--assignee", params.assignee], + ctx.cwd, signal, + ); + return { content: toContent(stdout, stderr, exitCode), details: {} }; + }, + renderCall(args, theme) { + return new Text( + theme.fg("toolTitle", theme.bold("bmo claim")) + " " + + theme.fg("accent", `BMO-${args.id}`) + " " + + theme.fg("dim", `→ ${args.assignee}`), + 0, 0, + ); + }, + renderResult(result, _opts, theme) { + if (result.isError) return new Text(theme.fg("error", "✗ ") + rawText(result), 0, 0); + return new Text(theme.fg("warning", "◐ ") + rawText(result), 0, 0); + }, + }); + + // ── bmo_close ───────────────────────────────────────────────────────────── + pi.registerTool({ + name: "bmo_close", + label: "bmo close", + description: + "Mark an issue as done. Only call after staff-engineer review passes with no open blockers.", + promptSnippet: "Close a bmo issue (mark done)", + promptGuidelines: [ + "Use bmo_close only after staff-engineer review has passed. Issues with open review blockers must not be closed.", + ], + parameters: Type.Object({ + id: Type.Union([Type.String(), Type.Number()], { description: "Issue ID" }), + }), + async execute(_id, params, signal, _u, ctx) { + const { stdout, stderr, exitCode } = await runBmo(["close", String(params.id)], ctx.cwd, signal); + return { content: toContent(stdout, stderr, exitCode), details: {} }; + }, + renderCall(args, theme) { + return new Text( + theme.fg("toolTitle", theme.bold("bmo close")) + " " + theme.fg("accent", `BMO-${args.id}`), + 0, 0, + ); + }, + renderResult(result, _opts, theme) { + if (result.isError) return new Text(theme.fg("error", "✗ ") + rawText(result), 0, 0); + return new Text(theme.fg("success", "✔ ") + rawText(result), 0, 0); + }, + }); + + // ── bmo_comment ─────────────────────────────────────────────────────────── + pi.registerTool({ + name: "bmo_comment", + label: "bmo comment", + description: "Add a comment to an issue, or list all comments on an issue.", + promptSnippet: "Add or list comments on a bmo issue", + parameters: Type.Object({ + action: StringEnum(["add", "list"] as const, { + description: "'add' to post a comment, 'list' to read existing comments", + }), + id: Type.Union([Type.String(), Type.Number()], { description: "Issue ID" }), + author: Type.Optional(Type.String({ description: "Comment author (required for action=add)" })), + body: Type.Optional(Type.String({ description: "Comment text (required for action=add)" })), + }), + async execute(_id, params, signal, _u, ctx) { + if (params.action === "add") { + if (!params.author || !params.body) { + return { + content: [{ type: "text", text: "Error: author and body are required when action=add" }], + details: {}, + isError: true, + }; + } + const { stdout, stderr, exitCode } = await runBmo( + ["comment", "add", String(params.id), "--author", params.author, "--body", params.body], + ctx.cwd, signal, + ); + return { content: toContent(stdout, stderr, exitCode), details: {} }; + } + const { stdout, stderr, exitCode } = await runBmo(["comment", "list", String(params.id)], ctx.cwd, signal); + return { content: toContent(stdout, stderr, exitCode), details: {} }; + }, + renderCall(args, theme) { + const action = String(args.action); + const suffix = action === "add" && args.author + ? " " + theme.fg("dim", `by ${args.author}`) + : ""; + return new Text( + theme.fg("toolTitle", theme.bold("bmo comment")) + " " + + theme.fg("accent", action) + " " + + theme.fg("dim", `BMO-${args.id}`) + suffix, + 0, 0, + ); + }, + renderResult(result, _opts, theme) { + if (result.isError) return new Text(theme.fg("error", "✗ ") + rawText(result), 0, 0); + + const data = parseResult(result); + // add action: plain text response + if (!data) return new Text(theme.fg("success", "✓ ") + rawText(result), 0, 0); + if (!data.ok) return new Text(theme.fg("error", data.error ?? rawText(result)), 0, 0); + + // list action: structured comment list + if (Array.isArray(data.data)) { + const comments = data.data as Array<{ author: string; body: string; created_at: string }>; + if (comments.length === 0) return new Text(theme.fg("dim", "No comments."), 0, 0); + const container = new Container(); + container.addChild(new Text(theme.fg("muted", `${comments.length} comment${comments.length !== 1 ? "s" : ""}:`), 0, 0)); + for (const c of comments) { + container.addChild(new Spacer(1)); + container.addChild(new Text(theme.fg("accent", c.author), 0, 0)); + for (const line of c.body.split("\n")) { + container.addChild(new Text(theme.fg("text", ` ${line}`), 0, 0)); + } + } + return container; + } + + return new Text(theme.fg("success", "✓ ") + (data.message ?? rawText(result)), 0, 0); + }, + }); + + // ── bmo_link ────────────────────────────────────────────────────────────── + pi.registerTool({ + name: "bmo_link", + label: "bmo link", + description: + "Add a dependency link between two issues. " + + "'blocks' means from_id must complete before to_id. " + + "WARNING: from_id and to_id must differ — bmo enforces a DAG and will reject cycles.", + promptSnippet: "Link two bmo issues with a dependency relation", + promptGuidelines: [ + "Verify from_id ≠ to_id before calling bmo_link — an issue cannot depend on itself.", + ], + parameters: Type.Object({ + from_id: Type.Union([Type.String(), Type.Number()], { description: "Source issue ID" }), + relation: StringEnum(["blocks", "blocked-by", "relates-to"] as const, { + description: "Relation type", + }), + to_id: Type.Union([Type.String(), Type.Number()], { + description: "Target issue ID (must differ from from_id)", + }), + }), + async execute(_id, params, signal, _u, ctx) { + if (String(params.from_id) === String(params.to_id)) { + return { + content: [{ type: "text", text: "Error: from_id and to_id must differ — an issue cannot depend on itself." }], + details: {}, + isError: true, + }; + } + const { stdout, stderr, exitCode } = await runBmo( + ["link", "add", String(params.from_id), params.relation, String(params.to_id)], + ctx.cwd, signal, + ); + return { content: toContent(stdout, stderr, exitCode), details: {} }; + }, + renderCall(args, theme) { + return new Text( + theme.fg("toolTitle", theme.bold("bmo link")) + " " + + theme.fg("accent", `BMO-${args.from_id}`) + " " + + theme.fg("muted", String(args.relation)) + " " + + theme.fg("accent", `BMO-${args.to_id}`), + 0, 0, + ); + }, + renderResult(result, _opts, theme) { + if (result.isError) return new Text(theme.fg("error", "✗ ") + rawText(result), 0, 0); + return new Text(theme.fg("success", "✓ ") + rawText(result), 0, 0); + }, + }); + + // ── bmo_stats ───────────────────────────────────────────────────────────── + pi.registerTool({ + name: "bmo_stats", + label: "bmo stats", + description: "Show issue counts broken down by status and priority.", + promptSnippet: "Get bmo issue statistics (counts by status and priority)", + parameters: Type.Object({}), + async execute(_id, _p, signal, _u, ctx) { + const { stdout, stderr, exitCode } = await runBmo(["stats"], ctx.cwd, signal); + return { content: toContent(stdout, stderr, exitCode), details: {} }; + }, + renderCall(_args, theme) { + return new Text(theme.fg("toolTitle", theme.bold("bmo stats")), 0, 0); + }, + renderResult(result, _opts, theme) { + const data = parseResult(result); + if (!data) return new Text(theme.fg("error", rawText(result)), 0, 0); + if (!data.ok) return new Text(theme.fg("error", data.error ?? rawText(result)), 0, 0); + + const stats = data.data as { + total: number; + by_status: Record; + by_priority: Record; + }; + + const container = new Container(); + container.addChild(new Text(theme.fg("muted", `Total: ${theme.fg("text", String(stats.total))}`), 0, 0)); + + if (stats.by_status) { + container.addChild(new Spacer(1)); + container.addChild(new Text(theme.fg("muted", "By status:"), 0, 0)); + for (const [status, count] of Object.entries(stats.by_status)) { + if (count === 0) continue; + const color = STATUS_COLOR[status] ?? "muted"; + container.addChild(new Text( + ` ${theme.fg(color, status.padEnd(14))} ${theme.fg("text", String(count))}`, + 0, 0, + )); + } + } + + if (stats.by_priority) { + container.addChild(new Spacer(1)); + container.addChild(new Text(theme.fg("muted", "By priority:"), 0, 0)); + for (const [priority, count] of Object.entries(stats.by_priority)) { + if (count === 0) continue; + const color = PRIORITY_COLOR[priority] ?? "dim"; + container.addChild(new Text( + ` ${theme.fg(color, priority.padEnd(10))} ${theme.fg("text", String(count))}`, + 0, 0, + )); + } + } + + return container; + }, + }); + + // ── bmo_reopen ──────────────────────────────────────────────────────────── + pi.registerTool({ + name: "bmo_reopen", + label: "bmo reopen", + description: "Reopen a closed issue, setting its status back to 'todo'.", + promptSnippet: "Reopen a closed bmo issue (sets status back to todo)", + parameters: Type.Object({ + id: Type.Union([Type.String(), Type.Number()], { description: "Issue ID" }), + }), + async execute(_id, params, signal, _u, ctx) { + const { stdout, stderr, exitCode } = await runBmo(["reopen", String(params.id)], ctx.cwd, signal); + return { content: toContent(stdout, stderr, exitCode), details: {} }; + }, + renderCall(args, theme) { + return new Text( + theme.fg("toolTitle", theme.bold("bmo reopen")) + " " + theme.fg("accent", `BMO-${args.id}`), + 0, 0, + ); + }, + renderResult(result, _opts, theme) { + if (result.isError) return new Text(theme.fg("error", "✗ ") + rawText(result), 0, 0); + return new Text(theme.fg("accent", "↺ ") + rawText(result), 0, 0); + }, + }); + + // ── bmo_file ────────────────────────────────────────────────────────────── + pi.registerTool({ + name: "bmo_file", + label: "bmo file", + description: + "Attach a file path to an issue. Call once per file immediately after bmo_create. " + + "File attachments drive parallel-agent collision detection.", + promptSnippet: "Attach a file path to a bmo issue (call once per file)", + parameters: Type.Object({ + id: Type.Union([Type.String(), Type.Number()], { description: "Issue ID" }), + path: Type.String({ description: "File path to attach (relative to project root)" }), + }), + async execute(_id, params, signal, _u, ctx) { + const { stdout, stderr, exitCode } = await runBmo( + ["file", "add", String(params.id), params.path], + ctx.cwd, signal, + ); + return { content: toContent(stdout, stderr, exitCode), details: {} }; + }, + renderCall(args, theme) { + return new Text( + theme.fg("toolTitle", theme.bold("bmo file")) + " " + + theme.fg("accent", `BMO-${args.id}`) + " " + + theme.fg("dim", String(args.path)), + 0, 0, + ); + }, + renderResult(result, _opts, theme) { + if (result.isError) return new Text(theme.fg("error", "✗ ") + rawText(result), 0, 0); + return new Text(theme.fg("success", "✓ ") + rawText(result), 0, 0); + }, + }); +} diff --git a/pi-extension/index.ts b/pi-extension/index.ts new file mode 100644 index 0000000..0750283 --- /dev/null +++ b/pi-extension/index.ts @@ -0,0 +1,257 @@ +/** + * pi-bmo extension + * + * Integrates bmo (local issue tracker) natively into pi-code: + * - bmo_* tools: typed wrappers for every bmo CLI command + * - Context injection: injects board state into the system prompt + * when a .bmo/issues.db is found in the project + * - Status widget: compact board counts above the pi editor + * - Slash commands: /bmo-board, /bmo-ls, /bmo-show, /bmo-next, + * /bmo-plan, /bmo-stats, /bmo-init, /bmo-widget + * + * Install (user-global): + * pi install git:github.com/erewok/bmo + * + * Or add to ~/.pi/agent/settings.json for local dev: + * { "extensions": ["/path/to/bmo/pi-extension"] } + */ + +import { execFile } from "node:child_process"; +import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; +import { registerBmoTools, hasBmoProject, getBoardCounts, getNextSummary } from "./bmo-tools.ts"; + +export default function (pi: ExtensionAPI): void { + // ── bmo_* tools ──────────────────────────────────────────────────────── + registerBmoTools(pi); + + // ── Context injection ────────────────────────────────────────────────── + // When an agent turn starts and a bmo project is active, silently inject + // the current board summary + next issues into the system prompt context. + pi.on("before_agent_start", async (_event, ctx) => { + if (!hasBmoProject(ctx.cwd)) return; + + const [counts, next] = await Promise.all([ + getBoardCounts(ctx.cwd), + getNextSummary(ctx.cwd), + ]); + if (!counts) return; + + const total = Object.values(counts).reduce((a, b) => a + b, 0); + if (total === 0 && !next) return; + + const statusLine = [ + `backlog:${counts.backlog}`, + `todo:${counts.todo}`, + `in-progress:${counts.in_progress}`, + `review:${counts.review}`, + `done:${counts.done}`, + ].join(" "); + + let content = `## Active bmo Project\n\nBoard: ${statusLine}`; + if (next) content += `\n\nNext work-ready:\n${next}`; + + return { + message: { + customType: "bmo-context", + content, + display: false, + }, + }; + }); + + // ── Status widget ────────────────────────────────────────────────────── + // Shown above the editor when a bmo project is active. + // Toggle with /bmo-widget. Preference persists across sessions. + + let widgetEnabled = true; + + // Restore saved preference from session entries on startup. + pi.on("session_start", async (event, ctx) => { + if (event.reason === "startup" || event.reason === "resume") { + for (const entry of ctx.sessionManager.getEntries()) { + if (entry.type === "custom" && entry.customType === "bmo-widget-pref") { + widgetEnabled = (entry as any).data?.enabled ?? true; + } + } + } + await refreshWidget(ctx.cwd, ctx.ui); + }); + + pi.on("agent_end", async (_event, ctx) => { + await refreshWidget(ctx.cwd, ctx.ui); + }); + + async function refreshWidget( + cwd: string, + ui: { setWidget(id: string, content: any): void }, + ): Promise { + if (!widgetEnabled || !hasBmoProject(cwd)) { + ui.setWidget("bmo", undefined); + return; + } + + const counts = await getBoardCounts(cwd); + if (!counts) { + ui.setWidget("bmo", undefined); + return; + } + + const total = Object.values(counts).reduce((a, b) => a + b, 0); + + if (total === 0) { + // Show a minimal presence so users know the widget is active. + ui.setWidget("bmo", ["bmo (no issues)"]); + return; + } + + // Only show non-zero counts — keeps the line compact. + const parts: string[] = ["bmo"]; + if (counts.backlog) parts.push(`${counts.backlog} backlog`); + if (counts.todo) parts.push(`${counts.todo} todo`); + if (counts.in_progress) parts.push(`${counts.in_progress} in-progress`); + if (counts.review) parts.push(`${counts.review} review`); + if (counts.done) parts.push(`${counts.done} done`); + + ui.setWidget("bmo", [parts.join(" ")]); + } + + // ── Slash commands ───────────────────────────────────────────────────── + + pi.registerCommand("bmo-widget", { + description: "Toggle the bmo board status widget on or off", + handler: async (_args, ctx) => { + widgetEnabled = !widgetEnabled; + pi.appendEntry("bmo-widget-pref", { enabled: widgetEnabled }); + if (widgetEnabled) { + await refreshWidget(ctx.cwd, ctx.ui); + ctx.ui.notify("bmo widget enabled", "info"); + } else { + ctx.ui.setWidget("bmo", undefined); + ctx.ui.notify("bmo widget disabled", "info"); + } + }, + }); + + pi.registerCommand("bmo-init", { + description: "Initialize a bmo project in the current directory", + handler: async (_args, ctx) => { + const { stdout, stderr } = await bmoExec(["init"], ctx.cwd); + ctx.ui.notify(stdout.trim() || stderr.trim() || "bmo initialized.", "info"); + await refreshWidget(ctx.cwd, ctx.ui); + }, + }); + + pi.registerCommand("bmo-ls", { + description: "List bmo issues. Optionally filter by status: /bmo-ls in-progress", + handler: async (args, ctx) => { + if (!hasBmoProject(ctx.cwd)) { + ctx.ui.notify("No bmo project found. Run /bmo-init first.", "warning"); + return; + } + const cliArgs = ["list"]; + const status = args?.trim(); + if (status) cliArgs.push("--status", status); + const { stdout, stderr } = await bmoExec(cliArgs, ctx.cwd); + pi.sendMessage( + { customType: "bmo-list", content: stripAnsi(stdout || stderr || "(no output)").trim(), display: true }, + { triggerTurn: false }, + ); + }, + }); + + pi.registerCommand("bmo-show", { + description: "Show a bmo issue with comments and relations: /bmo-show BMO-7", + handler: async (args, ctx) => { + if (!hasBmoProject(ctx.cwd)) { + ctx.ui.notify("No bmo project found. Run /bmo-init first.", "warning"); + return; + } + const id = args?.trim(); + if (!id) { + ctx.ui.notify("Usage: /bmo-show (e.g. /bmo-show BMO-7)", "warning"); + return; + } + const { stdout, stderr } = await bmoExec(["show", id], ctx.cwd); + pi.sendMessage( + { customType: "bmo-show", content: stripAnsi(stdout || stderr || "(no output)").trim(), display: true }, + { triggerTurn: false }, + ); + }, + }); + + pi.registerCommand("bmo-stats", { + description: "Show bmo issue counts by status and priority", + handler: async (_args, ctx) => { + if (!hasBmoProject(ctx.cwd)) { + ctx.ui.notify("No bmo project found. Run /bmo-init first.", "warning"); + return; + } + const { stdout, stderr } = await bmoExec(["stats"], ctx.cwd); + pi.sendMessage( + { customType: "bmo-stats", content: stripAnsi(stdout || stderr || "(no output)").trim(), display: true }, + { triggerTurn: false }, + ); + }, + }); + + pi.registerCommand("bmo-board", { + description: "Show the current bmo kanban board", + handler: async (_args, ctx) => { + if (!hasBmoProject(ctx.cwd)) { + ctx.ui.notify("No bmo project found. Run /bmo-init first.", "warning"); + return; + } + const { stdout, stderr } = await bmoExec(["board"], ctx.cwd); + pi.sendMessage( + { customType: "bmo-board", content: stripAnsi(stdout || stderr || "(no output)").trim(), display: true }, + { triggerTurn: false }, + ); + }, + }); + + pi.registerCommand("bmo-next", { + description: "Show next work-ready bmo issues", + handler: async (_args, ctx) => { + if (!hasBmoProject(ctx.cwd)) { + ctx.ui.notify("No bmo project found. Run /bmo-init first.", "warning"); + return; + } + const { stdout, stderr } = await bmoExec(["next"], ctx.cwd); + pi.sendMessage( + { customType: "bmo-next", content: stripAnsi(stdout || stderr || "(no output)").trim(), display: true }, + { triggerTurn: false }, + ); + }, + }); + + pi.registerCommand("bmo-plan", { + description: "Show the phased bmo execution plan", + handler: async (_args, ctx) => { + if (!hasBmoProject(ctx.cwd)) { + ctx.ui.notify("No bmo project found. Run /bmo-init first.", "warning"); + return; + } + const { stdout, stderr } = await bmoExec(["plan"], ctx.cwd); + pi.sendMessage( + { customType: "bmo-plan", content: stripAnsi(stdout || stderr || "(no output)").trim(), display: true }, + { triggerTurn: false }, + ); + }, + }); +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function bmoExec(args: string[], cwd: string): Promise<{ stdout: string; stderr: string }> { + return new Promise((resolve) => { + execFile("bmo", args, { cwd }, (_err, stdout, stderr) => resolve({ stdout, stderr })); + }); +} + +/** Strip ANSI escape codes so raw bmo CLI output displays cleanly in chat. */ +function stripAnsi(str: string): string { + // eslint-disable-next-line no-control-regex + return str.replace(/\x1b\[[0-9;]*[mGKHF]/g, ""); +} diff --git a/pi-extension/package.json b/pi-extension/package.json new file mode 100644 index 0000000..cf8666d --- /dev/null +++ b/pi-extension/package.json @@ -0,0 +1,20 @@ +{ + "name": "pi-bmo", + "version": "0.1.0", + "description": "Pi-code extension for bmo — native bmo_* tools, board context injection, status widget, and slash commands", + "keywords": ["pi-package", "bmo", "issue-tracker"], + "license": "Apache-2.0", + "repository": { + "type": "git", + "url": "https://github.com/erewok/bmo" + }, + "pi": { + "extensions": ["./index.ts"] + }, + "peerDependencies": { + "@earendil-works/pi-coding-agent": "*", + "@earendil-works/pi-ai": "*", + "@earendil-works/pi-tui": "*", + "typebox": "*" + } +} From 7369f741d26c3cb6e8ad454d9ddbfa31a5e79357 Mon Sep 17 00:00:00 2001 From: Erik Aker Date: Thu, 21 May 2026 11:05:45 -0700 Subject: [PATCH 2/3] No use --- README.md | 2 +- docs/pi-extension.md | 74 +-- pi-extension/bmo-tools.ts | 957 -------------------------------------- pi-extension/index.ts | 257 ---------- pi-extension/package.json | 20 - 5 files changed, 46 insertions(+), 1264 deletions(-) delete mode 100644 pi-extension/bmo-tools.ts delete mode 100644 pi-extension/index.ts delete mode 100644 pi-extension/package.json diff --git a/README.md b/README.md index 0814c5f..90c8b22 100644 --- a/README.md +++ b/README.md @@ -51,7 +51,7 @@ into pi. This extension adds typed `bmo_*` tools that the LLM calls directly, in state injected into every agent turn's context, and `/board` `/next` `/plan` slash commands. ```bash -pi install git:github.com/erewok/bmo +pi install git:github.com/erewok/pi-bmo ``` A companion pi-code extension, [bmo-dev-team](https://github.com/erewok/bmo-agent-setup), adds a diff --git a/docs/pi-extension.md b/docs/pi-extension.md index f7fd5a3..e6fe9a7 100644 --- a/docs/pi-extension.md +++ b/docs/pi-extension.md @@ -40,49 +40,33 @@ current working directory looking for `.bmo/issues.db`. No configuration needed. ## Installation +> **Prerequisite:** The `bmo` binary must already be installed and on your PATH before +> the extension will work. See [Requirements](#requirements) below. + Install the extension into pi's global configuration: ```bash -pi install git:github.com/erewok/bmo +pi install git:github.com/erewok/pi-bmo ``` -pi clones the repo, runs `npm install`, and loads the extension from `pi-extension/` -automatically on next start. - To try it without making it permanent: ```bash -pi -e git:github.com/erewok/bmo +pi -e git:github.com/erewok/pi-bmo ``` -### Local Development - -If you have the bmo repo checked out locally, add the extension path to -`~/.pi/agent/settings.json` instead: - -```json -{ - "extensions": ["/path/to/bmo/pi-extension"] -} +See the [pi-bmo repo](https://github.com/erewok/pi-bmo) for full installation, +update, and local development instructions. ``` ## Dev-Team Extension -A companion extension, [bmo-dev-team](https://github.com/erewok/bmo-agent-setup), +A companion extension, [pi-bmo-agents](https://github.com/erewok/bmo-agent-setup), builds on `pi-bmo` to provide a full AI software development team that plans and -executes work through bmo: - -- **`staff-engineer`** — produces Technical Design Documents and reviews all implementation changes -- **`project-manager`** — decomposes requests into bmo issues with dependency graphs and file scoping -- **`senior-engineer`** — implements from bmo issues within their scoped files -- **`qa-engineer`** — verifies acceptance criteria and posts results as bmo comments -- **`ux-designer`** — produces UX design specs before technical planning -- **`dev-team` skill** — orchestration skill that sequences the team: design → plan → implement → review → verify - -Installing `bmo-dev-team` also pulls in `pi-bmo` automatically — you do not need to -install both separately: +executes work through bmo. Install both: ```bash +pi install git:github.com/erewok/pi-bmo pi install git:github.com/erewok/bmo-agent-setup ``` @@ -106,7 +90,39 @@ detect collisions before spawning parallel agents and serialize any conflicting ## Requirements -- [bmo](https://github.com/erewok/bmo) installed and on `$PATH` -- [pi-code](https://pi.dev) installed +- **[bmo](https://github.com/erewok/bmo) binary** installed and available on your shell's `$PATH` +- **[pi-code](https://pi.dev)** installed + +The extension has no additional TypeScript/npm runtime dependencies. + +### Installing the bmo binary + +`pi install` only installs the TypeScript extension — it does **not** install the `bmo` +binary for you. The extension calls `bmo` directly using Node's `execFile`, so the binary +must be on the PATH that pi inherits when it starts. + +```bash +# From source (inside the bmo repo) +cargo install --path . --locked + +# Or from crates.io once published +cargo install bmo +``` + +Verify with `which bmo`. If that prints nothing, the binary is not on PATH. + +### PATH and shell environments + +On macOS, `~/.cargo/bin` is added to `$PATH` by Cargo's installer and is available in +terminal shells. However, if you launch pi from a GUI launcher (Spotlight, Alfred, a +menu-bar app, etc.), the process may inherit a minimal `PATH` that does not include +`~/.cargo/bin`. The safest fix is to start pi from a terminal session where `which bmo` +works. If you must launch pi another way, add `~/.cargo/bin` to `/etc/paths` or to your +shell's login profile (`~/.zprofile` for zsh, `~/.bash_profile` for bash). + +### What happens if bmo is missing -The extension has no additional runtime dependencies. +The extension will show a warning notification at session start if `bmo` cannot be found. +Every `bmo_*` tool call will also return a descriptive error rather than failing silently, +but the LLM will not be able to perform any issue-tracking operations until the binary is +installed and accessible. diff --git a/pi-extension/bmo-tools.ts b/pi-extension/bmo-tools.ts deleted file mode 100644 index 0a42bfe..0000000 --- a/pi-extension/bmo-tools.ts +++ /dev/null @@ -1,957 +0,0 @@ -/** - * Native bmo tools for pi-code. - * - * Each tool wraps a bmo CLI command with typed parameters and structured - * JSON output, so the LLM never needs to remember raw CLI flags. - * - * All tools run `bmo` with cwd set to ctx.cwd, letting bmo auto-discover - * .bmo/issues.db the same way git discovers .git — by walking up from cwd. - */ - -import { execFile } from "node:child_process"; -import * as path from "node:path"; -import * as fs from "node:fs"; -import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; -import { StringEnum } from "@earendil-works/pi-ai"; -import { Container, Spacer, Text } from "@earendil-works/pi-tui"; -import { Type } from "typebox"; - -// --------------------------------------------------------------------------- -// Internal helpers -// --------------------------------------------------------------------------- - -function runBmo( - args: string[], - cwd: string, - signal?: AbortSignal, -): Promise<{ stdout: string; stderr: string; exitCode: number }> { - return new Promise((resolve) => { - const proc = execFile("bmo", [...args, "--json"], { cwd, shell: false }, (err, stdout, stderr) => { - resolve({ stdout, stderr, exitCode: (err as any)?.code ?? 0 }); - }); - if (signal) { - const kill = () => proc.kill("SIGTERM"); - if (signal.aborted) kill(); - else signal.addEventListener("abort", kill, { once: true }); - } - }); -} - -function toContent(stdout: string, stderr: string, exitCode: number): { type: "text"; text: string }[] { - if (stdout.trim()) { - try { - return [{ type: "text", text: JSON.stringify(JSON.parse(stdout.trim()), null, 2) }]; - } catch { - return [{ type: "text", text: stdout.trim() }]; - } - } - if (stderr.trim()) return [{ type: "text", text: `Error: ${stderr.trim()}` }]; - return [{ type: "text", text: exitCode === 0 ? "(success)" : `(exit code ${exitCode})` }]; -} - -/** Parse the JSON response from a tool result's content text. Returns null on failure. */ -function parseResult(result: { content: { type: string; text?: string }[] }): any | null { - const block = result.content[0]; - if (!block || block.type !== "text" || !block.text) return null; - try { return JSON.parse(block.text); } catch { return null; } -} - -/** Fallback: return the raw text from a tool result. */ -function rawText(result: { content: { type: string; text?: string }[] }): string { - const block = result.content[0]; - return (block?.type === "text" && block.text) ? block.text : "(no output)"; -} - -const STATUS_COLOR: Record = { - backlog: "muted", - todo: "accent", - "in-progress": "warning", - "in_progress": "warning", - review: "text", - done: "success", -}; - -const PRIORITY_COLOR: Record = { - critical: "error", - high: "warning", - medium: "accent", - low: "muted", - none: "dim", -}; - -// --------------------------------------------------------------------------- -// Exported helpers (used by index.ts for widget and context injection) -// --------------------------------------------------------------------------- - -/** Walk up from cwd looking for .bmo/issues.db — same discovery logic as bmo itself. */ -export function hasBmoProject(cwd: string): boolean { - let dir = cwd; - while (true) { - if (fs.existsSync(path.join(dir, ".bmo", "issues.db"))) return true; - const parent = path.dirname(dir); - if (parent === dir) return false; - dir = parent; - } -} - -/** Return board counts by status column, or null if no bmo project or on error. */ -export async function getBoardCounts(cwd: string): Promise | null> { - if (!hasBmoProject(cwd)) return null; - const { stdout } = await runBmo(["board"], cwd); - try { - const data = JSON.parse(stdout.trim()); - if (!data.ok) return null; - const board = data.data as Record; - return { - backlog: board.backlog?.length ?? 0, - todo: board.todo?.length ?? 0, - in_progress: board.in_progress?.length ?? 0, - review: board.review?.length ?? 0, - done: board.done?.length ?? 0, - }; - } catch { - return null; - } -} - -/** Return a compact summary of next work-ready issues, or null if none / no project. */ -export async function getNextSummary(cwd: string): Promise { - if (!hasBmoProject(cwd)) return null; - const { stdout } = await runBmo(["next"], cwd); - try { - const data = JSON.parse(stdout.trim()); - if (!data.ok || !Array.isArray(data.data) || data.data.length === 0) return null; - return (data.data as Array<{ id: number; title: string; status: string }>) - .map((i) => `BMO-${i.id}: ${i.title} [${i.status}]`) - .join("\n"); - } catch { - return null; - } -} - -// --------------------------------------------------------------------------- -// Tool registration -// --------------------------------------------------------------------------- - -export function registerBmoTools(pi: ExtensionAPI): void { - - // ── bmo_init ────────────────────────────────────────────────────────────── - pi.registerTool({ - name: "bmo_init", - label: "bmo init", - description: - "Initialize a bmo issue-tracking project in the current directory. " + - "Creates .bmo/issues.db. Safe to call again — shows current status if already initialized.", - promptSnippet: "Initialize or check bmo project in the current directory", - parameters: Type.Object({}), - async execute(_id, _p, signal, _u, ctx) { - const { stdout, stderr, exitCode } = await new Promise<{ stdout: string; stderr: string; exitCode: number }>( - (resolve) => { - const proc = execFile("bmo", ["init"], { cwd: ctx.cwd, shell: false }, (err, stdout, stderr) => { - resolve({ stdout, stderr, exitCode: (err as any)?.code ?? 0 }); - }); - if (signal) { - const kill = () => proc.kill("SIGTERM"); - if (signal.aborted) kill(); - else signal.addEventListener("abort", kill, { once: true }); - } - }, - ); - const text = stdout.trim() || stderr.trim() || (exitCode === 0 ? "bmo initialized." : `Exit ${exitCode}`); - return { content: [{ type: "text", text }], details: {} }; - }, - renderResult(result, _opts, theme) { - const text = rawText(result); - const icon = result.isError ? theme.fg("error", "✗ ") : theme.fg("success", "✓ "); - return new Text(icon + text, 0, 0); - }, - }); - - // ── bmo_agent_init ──────────────────────────────────────────────────────── - pi.registerTool({ - name: "bmo_agent_init", - label: "bmo agent-init", - description: - "One-shot session orientation: initializes bmo if needed, then prints board, " + - "next work-ready issues, stats, and a command cheat sheet. " + - "Call this at the start of every agent session that touches bmo.", - promptSnippet: "Orient to the current bmo project (board + next + stats + cheat sheet)", - promptGuidelines: [ - "Use bmo_agent_init at the start of every agent session before any planning or implementation.", - ], - parameters: Type.Object({}), - async execute(_id, _p, signal, _u, ctx) { - const { stdout, stderr } = await new Promise<{ stdout: string; stderr: string }>((resolve) => { - const proc = execFile("bmo", ["agent-init"], { cwd: ctx.cwd, shell: false }, (_err, stdout, stderr) => { - resolve({ stdout, stderr }); - }); - if (signal) { - const kill = () => proc.kill("SIGTERM"); - if (signal.aborted) kill(); - else signal.addEventListener("abort", kill, { once: true }); - } - }); - return { content: [{ type: "text", text: stdout.trim() || stderr.trim() || "(no output)" }], details: {} }; - }, - }); - - // ── bmo_board ───────────────────────────────────────────────────────────── - pi.registerTool({ - name: "bmo_board", - label: "bmo board", - description: "Return the full kanban board organized by status column (backlog / todo / in-progress / review / done).", - promptSnippet: "Get the full bmo kanban board as structured JSON", - parameters: Type.Object({}), - async execute(_id, _p, signal, _u, ctx) { - const { stdout, stderr, exitCode } = await runBmo(["board"], ctx.cwd, signal); - return { content: toContent(stdout, stderr, exitCode), details: {} }; - }, - renderCall(_args, theme) { - return new Text(theme.fg("toolTitle", theme.bold("bmo board")), 0, 0); - }, - renderResult(result, _opts, theme) { - const data = parseResult(result); - if (!data) return new Text(theme.fg("error", rawText(result)), 0, 0); - if (!data.ok) return new Text(theme.fg("error", data.error ?? rawText(result)), 0, 0); - - const board = data.data as Record>; - const columns = [ - { key: "backlog", label: "Backlog", color: "muted" }, - { key: "todo", label: "Todo", color: "accent" }, - { key: "in_progress", label: "In Progress", color: "warning" }, - { key: "review", label: "Review", color: "text" }, - { key: "done", label: "Done", color: "success" }, - ] as const; - - const total = columns.reduce((n, c) => n + (board[c.key]?.length ?? 0), 0); - if (total === 0) { - return new Text(theme.fg("dim", "Board is empty — no issues yet."), 0, 0); - } - - const container = new Container(); - for (const col of columns) { - const issues = board[col.key] ?? []; - if (issues.length === 0) continue; - - const header = theme.fg(col.color, `${col.label.toUpperCase()} (${issues.length})`); - container.addChild(new Text(header, 0, 0)); - for (const issue of issues.slice(0, 8)) { - const priorityMark = issue.priority === "critical" ? theme.fg("error", "!") : - issue.priority === "high" ? theme.fg("warning", "↑") : " "; - container.addChild(new Text( - ` ${priorityMark} ${theme.fg("dim", `BMO-${issue.id}`)} ${issue.title}`, - 0, 0, - )); - } - if (issues.length > 8) { - container.addChild(new Text(theme.fg("muted", ` … ${issues.length - 8} more`), 0, 0)); - } - container.addChild(new Spacer(1)); - } - return container; - }, - }); - - // ── bmo_plan ────────────────────────────────────────────────────────────── - pi.registerTool({ - name: "bmo_plan", - label: "bmo plan", - description: - "Return the phased execution plan derived from the issue dependency graph. " + - "Issues in the same phase can run in parallel. " + - "Filter to a specific phase number with the optional `phase` parameter.", - promptSnippet: "Get the phased bmo execution plan (all phases, or a specific phase)", - parameters: Type.Object({ - phase: Type.Optional(Type.Number({ description: "Show only this phase number (1-indexed)" })), - }), - async execute(_id, params, signal, _u, ctx) { - const args = ["plan"]; - if (params.phase != null) args.push("--phase", String(params.phase)); - const { stdout, stderr, exitCode } = await runBmo(args, ctx.cwd, signal); - return { content: toContent(stdout, stderr, exitCode), details: {} }; - }, - renderCall(args, theme) { - const suffix = args.phase != null ? theme.fg("dim", ` phase ${args.phase}`) : ""; - return new Text(theme.fg("toolTitle", theme.bold("bmo plan")) + suffix, 0, 0); - }, - renderResult(result, _opts, theme) { - const data = parseResult(result); - if (!data) return new Text(theme.fg("error", rawText(result)), 0, 0); - if (!data.ok) return new Text(theme.fg("error", data.error ?? rawText(result)), 0, 0); - - const { phases, total_phases } = data.data as { - phases: Array<{ number: number; issues: Array<{ id: number; title: string }> }>; - total_phases: number; - }; - - if (total_phases === 0) return new Text(theme.fg("dim", "No phases — board is empty."), 0, 0); - - const container = new Container(); - for (const phase of phases) { - container.addChild(new Text( - theme.fg("accent", `Phase ${phase.number}`) + - theme.fg("dim", ` (${phase.issues.length} issue${phase.issues.length !== 1 ? "s" : ""}, can run in parallel)`), - 0, 0, - )); - for (const issue of phase.issues) { - container.addChild(new Text( - ` ${theme.fg("dim", `BMO-${issue.id}`)} ${issue.title}`, - 0, 0, - )); - } - container.addChild(new Spacer(1)); - } - return container; - }, - }); - - // ── bmo_next ────────────────────────────────────────────────────────────── - pi.registerTool({ - name: "bmo_next", - label: "bmo next", - description: "Return next work-ready issues: status todo or backlog with all blocking issues resolved.", - promptSnippet: "Get next work-ready bmo issues (all blockers resolved)", - parameters: Type.Object({}), - async execute(_id, _p, signal, _u, ctx) { - const { stdout, stderr, exitCode } = await runBmo(["next"], ctx.cwd, signal); - return { content: toContent(stdout, stderr, exitCode), details: {} }; - }, - renderCall(_args, theme) { - return new Text(theme.fg("toolTitle", theme.bold("bmo next")), 0, 0); - }, - renderResult(result, _opts, theme) { - const data = parseResult(result); - if (!data) return new Text(theme.fg("error", rawText(result)), 0, 0); - if (!data.ok) return new Text(theme.fg("error", data.error ?? rawText(result)), 0, 0); - - const issues = data.data as Array<{ id: number; title: string; priority: string; status: string }>; - if (issues.length === 0) return new Text(theme.fg("dim", "No work-ready issues."), 0, 0); - - const container = new Container(); - container.addChild(new Text(theme.fg("muted", `${issues.length} work-ready:`), 0, 0)); - for (const issue of issues) { - const pColor = PRIORITY_COLOR[issue.priority] ?? "dim"; - container.addChild(new Text( - ` ${theme.fg("dim", `BMO-${issue.id}`)} ${issue.title} ${theme.fg(pColor, issue.priority)}`, - 0, 0, - )); - } - return container; - }, - }); - - // ── bmo_show ────────────────────────────────────────────────────────────── - pi.registerTool({ - name: "bmo_show", - label: "bmo show", - description: - "Return full details for an issue: title, description, status, priority, assignee, " + - "comments, relations (blocks / blocked-by), sub-issues, and attached files.", - promptSnippet: "Get full details for a bmo issue including comments and relations", - parameters: Type.Object({ - id: Type.Union([Type.String(), Type.Number()], { - description: "Issue ID — numeric (1) or BMO-prefixed (BMO-1)", - }), - }), - async execute(_id, params, signal, _u, ctx) { - const { stdout, stderr, exitCode } = await runBmo(["show", String(params.id)], ctx.cwd, signal); - return { content: toContent(stdout, stderr, exitCode), details: {} }; - }, - renderCall(args, theme) { - return new Text( - theme.fg("toolTitle", theme.bold("bmo show")) + " " + theme.fg("accent", String(args.id)), - 0, 0, - ); - }, - renderResult(result, _opts, theme) { - const data = parseResult(result); - if (!data) return new Text(theme.fg("error", rawText(result)), 0, 0); - if (!data.ok) return new Text(theme.fg("error", data.error ?? rawText(result)), 0, 0); - - const { issue, comments = [], relations = [], sub_issues = [] } = data.data as { - issue: { - id: number; title: string; status: string; priority: string; - assignee: string | null; description: string; files: string[]; - }; - comments: Array<{ author: string; body: string; created_at: string }>; - relations: Array<{ from_id: number; to_id: number; kind: string }>; - sub_issues: Array<{ id: number; title: string; status: string }>; - }; - - const sColor = STATUS_COLOR[issue.status] ?? "muted"; - const pColor = PRIORITY_COLOR[issue.priority] ?? "dim"; - const container = new Container(); - - // Title + ID - container.addChild(new Text( - theme.fg("accent", theme.bold(`BMO-${issue.id}`)) + " " + theme.bold(issue.title), - 0, 0, - )); - - // Status / priority / assignee - const meta = [ - theme.fg(sColor, issue.status), - theme.fg(pColor, issue.priority), - issue.assignee ? theme.fg("dim", `→ ${issue.assignee}`) : "", - ].filter(Boolean).join(" "); - container.addChild(new Text(meta, 0, 0)); - - // Description - if (issue.description?.trim()) { - container.addChild(new Spacer(1)); - const lines = issue.description.trim().split("\n").slice(0, 6); - for (const line of lines) container.addChild(new Text(theme.fg("text", line), 0, 0)); - if (issue.description.split("\n").length > 6) { - container.addChild(new Text(theme.fg("muted", " …"), 0, 0)); - } - } - - // Files - if (issue.files?.length) { - container.addChild(new Spacer(1)); - container.addChild(new Text(theme.fg("muted", "files:"), 0, 0)); - for (const f of issue.files) { - container.addChild(new Text(theme.fg("dim", ` ${f}`), 0, 0)); - } - } - - // Relations - if (relations.length) { - container.addChild(new Spacer(1)); - for (const rel of relations) { - const other = rel.from_id === issue.id ? rel.to_id : rel.from_id; - container.addChild(new Text( - theme.fg("muted", rel.kind) + " " + theme.fg("dim", `BMO-${other}`), - 0, 0, - )); - } - } - - // Sub-issues - if (sub_issues.length) { - container.addChild(new Spacer(1)); - container.addChild(new Text(theme.fg("muted", `${sub_issues.length} sub-issue${sub_issues.length !== 1 ? "s" : ""}:`), 0, 0)); - for (const sub of sub_issues) { - const sCol = STATUS_COLOR[sub.status] ?? "muted"; - container.addChild(new Text( - ` ${theme.fg("dim", `BMO-${sub.id}`)} ${sub.title} ${theme.fg(sCol, sub.status)}`, - 0, 0, - )); - } - } - - // Comments - if (comments.length) { - container.addChild(new Spacer(1)); - container.addChild(new Text(theme.fg("muted", `${comments.length} comment${comments.length !== 1 ? "s" : ""}:`), 0, 0)); - for (const c of comments.slice(-5)) { - const preview = c.body.length > 100 ? c.body.slice(0, 100) + "…" : c.body; - container.addChild(new Text( - ` ${theme.fg("accent", c.author)} ${theme.fg("text", preview)}`, - 0, 0, - )); - } - if (comments.length > 5) { - container.addChild(new Text(theme.fg("dim", ` … ${comments.length - 5} earlier`), 0, 0)); - } - } - - return container; - }, - }); - - // ── bmo_list ────────────────────────────────────────────────────────────── - pi.registerTool({ - name: "bmo_list", - label: "bmo list", - description: "List issues with optional filters.", - promptSnippet: "List bmo issues (optional status / assignee filter)", - parameters: Type.Object({ - status: Type.Optional( - StringEnum(["backlog", "todo", "in-progress", "review", "done"] as const, { - description: "Filter by status", - }), - ), - assignee: Type.Optional(Type.String({ description: "Filter by assignee name" })), - }), - async execute(_id, params, signal, _u, ctx) { - const args = ["list"]; - if (params.status) args.push("--status", params.status); - if (params.assignee) args.push("--assignee", params.assignee); - const { stdout, stderr, exitCode } = await runBmo(args, ctx.cwd, signal); - return { content: toContent(stdout, stderr, exitCode), details: {} }; - }, - renderCall(args, theme) { - const filters = [ - args.status ? theme.fg("accent", String(args.status)) : "", - args.assignee ? theme.fg("dim", `@${args.assignee}`) : "", - ].filter(Boolean).join(" "); - return new Text( - theme.fg("toolTitle", theme.bold("bmo list")) + (filters ? " " + filters : ""), - 0, 0, - ); - }, - renderResult(result, _opts, theme) { - const data = parseResult(result); - if (!data) return new Text(theme.fg("error", rawText(result)), 0, 0); - if (!data.ok) return new Text(theme.fg("error", data.error ?? rawText(result)), 0, 0); - - const issues = data.data as Array<{ - id: number; title: string; status: string; priority: string; assignee: string | null; - }>; - if (issues.length === 0) return new Text(theme.fg("dim", "No issues found."), 0, 0); - - const container = new Container(); - container.addChild(new Text(theme.fg("muted", `${issues.length} issue${issues.length !== 1 ? "s" : ""}:`), 0, 0)); - for (const issue of issues) { - const sColor = STATUS_COLOR[issue.status] ?? "muted"; - const pColor = PRIORITY_COLOR[issue.priority] ?? "dim"; - const assignee = issue.assignee ? theme.fg("dim", ` → ${issue.assignee}`) : ""; - container.addChild(new Text( - ` ${theme.fg("dim", `BMO-${issue.id}`)} ${issue.title} ` + - `${theme.fg(sColor, issue.status)} ${theme.fg(pColor, issue.priority)}${assignee}`, - 0, 0, - )); - } - return container; - }, - }); - - // ── bmo_create ──────────────────────────────────────────────────────────── - pi.registerTool({ - name: "bmo_create", - label: "bmo create", - description: - "Create a new bmo issue. Call bmo_file immediately after to attach all files scoped to this issue.", - promptSnippet: "Create a new bmo issue", - promptGuidelines: [ - "After bmo_create, immediately call bmo_file for every file scoped to the new issue.", - ], - parameters: Type.Object({ - title: Type.String({ description: "Issue title" }), - description: Type.Optional(Type.String({ description: "Issue body" })), - parent: Type.Optional( - Type.Union([Type.String(), Type.Number()], { description: "Parent issue ID" }), - ), - priority: Type.Optional( - StringEnum(["critical", "high", "medium", "low", "none"] as const, { - description: "Priority (default: medium)", - }), - ), - kind: Type.Optional( - StringEnum(["task", "bug", "feature", "chore"] as const, { - description: "Issue kind (default: task)", - }), - ), - }), - async execute(_id, params, signal, _u, ctx) { - const args = ["create", "-t", params.title]; - if (params.description) args.push("-d", params.description); - if (params.parent != null) args.push("--parent", String(params.parent)); - if (params.priority) args.push("-p", params.priority); - if (params.kind) args.push("-k", params.kind); - const { stdout, stderr, exitCode } = await runBmo(args, ctx.cwd, signal); - return { content: toContent(stdout, stderr, exitCode), details: {} }; - }, - renderCall(args, theme) { - const title = String(args.title ?? ""); - const preview = title.length > 50 ? title.slice(0, 50) + "…" : title; - const suffix = args.priority && args.priority !== "medium" - ? " " + theme.fg(PRIORITY_COLOR[String(args.priority)] ?? "dim", String(args.priority)) - : ""; - return new Text( - theme.fg("toolTitle", theme.bold("bmo create")) + " " + theme.fg("text", preview) + suffix, - 0, 0, - ); - }, - renderResult(result, _opts, theme) { - if (result.isError) return new Text(theme.fg("error", "✗ ") + rawText(result), 0, 0); - // bmo create returns plain text like "Created BMO-7: title" - return new Text(theme.fg("success", "✓ ") + rawText(result), 0, 0); - }, - }); - - // ── bmo_edit ────────────────────────────────────────────────────────────── - pi.registerTool({ - name: "bmo_edit", - label: "bmo edit", - description: "Edit one or more fields on an existing issue. Only provided fields are updated.", - promptSnippet: "Edit bmo issue fields (title, description, status, priority, assignee, parent)", - parameters: Type.Object({ - id: Type.Union([Type.String(), Type.Number()], { description: "Issue ID" }), - title: Type.Optional(Type.String()), - description: Type.Optional(Type.String()), - status: Type.Optional( - StringEnum(["backlog", "todo", "in-progress", "review", "done"] as const), - ), - priority: Type.Optional( - StringEnum(["critical", "high", "medium", "low", "none"] as const), - ), - assignee: Type.Optional(Type.String({ description: "Pass empty string to clear assignee" })), - parent: Type.Optional(Type.Union([Type.String(), Type.Number()])), - }), - async execute(_id, params, signal, _u, ctx) { - const args = ["edit", String(params.id)]; - if (params.title) args.push("-t", params.title); - if (params.description) args.push("-d", params.description); - if (params.status) args.push("-s", params.status); - if (params.priority) args.push("-p", params.priority); - if (params.assignee !== undefined) args.push("-a", params.assignee); - if (params.parent != null) args.push("--parent", String(params.parent)); - const { stdout, stderr, exitCode } = await runBmo(args, ctx.cwd, signal); - return { content: toContent(stdout, stderr, exitCode), details: {} }; - }, - renderCall(args, theme) { - const changes: string[] = []; - if (args.status) changes.push(theme.fg(STATUS_COLOR[String(args.status)] ?? "muted", String(args.status))); - if (args.priority) changes.push(theme.fg(PRIORITY_COLOR[String(args.priority)] ?? "dim", String(args.priority))); - if (args.assignee !== undefined) changes.push(theme.fg("dim", args.assignee ? `→ ${args.assignee}` : "unassigned")); - if (args.title) changes.push(theme.fg("text", `title: "${String(args.title).slice(0, 30)}"`)); - return new Text( - theme.fg("toolTitle", theme.bold("bmo edit")) + " " + - theme.fg("accent", `BMO-${args.id}`) + - (changes.length ? " " + changes.join(" ") : ""), - 0, 0, - ); - }, - }); - - // ── bmo_move ────────────────────────────────────────────────────────────── - pi.registerTool({ - name: "bmo_move", - label: "bmo move", - description: "Change the status of an issue.", - promptSnippet: "Move a bmo issue to a new status", - parameters: Type.Object({ - id: Type.Union([Type.String(), Type.Number()], { description: "Issue ID" }), - status: StringEnum(["backlog", "todo", "in-progress", "review", "done"] as const, { - description: "Target status", - }), - }), - async execute(_id, params, signal, _u, ctx) { - const { stdout, stderr, exitCode } = await runBmo( - ["move", String(params.id), "-s", params.status], - ctx.cwd, signal, - ); - return { content: toContent(stdout, stderr, exitCode), details: {} }; - }, - renderCall(args, theme) { - const sColor = STATUS_COLOR[String(args.status)] ?? "muted"; - return new Text( - theme.fg("toolTitle", theme.bold("bmo move")) + " " + - theme.fg("accent", `BMO-${args.id}`) + " → " + - theme.fg(sColor, String(args.status)), - 0, 0, - ); - }, - renderResult(result, _opts, theme) { - if (result.isError) return new Text(theme.fg("error", "✗ ") + rawText(result), 0, 0); - return new Text(theme.fg("success", "✓ ") + rawText(result), 0, 0); - }, - }); - - // ── bmo_claim ───────────────────────────────────────────────────────────── - pi.registerTool({ - name: "bmo_claim", - label: "bmo claim", - description: - "Atomically claim an issue: sets status to 'in-progress' and records the assignee. " + - "Call this before starting work on any issue to prevent duplicate effort.", - promptSnippet: "Atomically claim a bmo issue (sets in-progress + assignee)", - promptGuidelines: [ - "Use bmo_claim before starting implementation work. Set assignee to a unique agent reference like 'se-BMO-3-1716000000'.", - ], - parameters: Type.Object({ - id: Type.Union([Type.String(), Type.Number()], { description: "Issue ID" }), - assignee: Type.String({ - description: "Unique agent reference, e.g. 'se-BMO-3-1716000000'", - }), - }), - async execute(_id, params, signal, _u, ctx) { - const { stdout, stderr, exitCode } = await runBmo( - ["claim", String(params.id), "--assignee", params.assignee], - ctx.cwd, signal, - ); - return { content: toContent(stdout, stderr, exitCode), details: {} }; - }, - renderCall(args, theme) { - return new Text( - theme.fg("toolTitle", theme.bold("bmo claim")) + " " + - theme.fg("accent", `BMO-${args.id}`) + " " + - theme.fg("dim", `→ ${args.assignee}`), - 0, 0, - ); - }, - renderResult(result, _opts, theme) { - if (result.isError) return new Text(theme.fg("error", "✗ ") + rawText(result), 0, 0); - return new Text(theme.fg("warning", "◐ ") + rawText(result), 0, 0); - }, - }); - - // ── bmo_close ───────────────────────────────────────────────────────────── - pi.registerTool({ - name: "bmo_close", - label: "bmo close", - description: - "Mark an issue as done. Only call after staff-engineer review passes with no open blockers.", - promptSnippet: "Close a bmo issue (mark done)", - promptGuidelines: [ - "Use bmo_close only after staff-engineer review has passed. Issues with open review blockers must not be closed.", - ], - parameters: Type.Object({ - id: Type.Union([Type.String(), Type.Number()], { description: "Issue ID" }), - }), - async execute(_id, params, signal, _u, ctx) { - const { stdout, stderr, exitCode } = await runBmo(["close", String(params.id)], ctx.cwd, signal); - return { content: toContent(stdout, stderr, exitCode), details: {} }; - }, - renderCall(args, theme) { - return new Text( - theme.fg("toolTitle", theme.bold("bmo close")) + " " + theme.fg("accent", `BMO-${args.id}`), - 0, 0, - ); - }, - renderResult(result, _opts, theme) { - if (result.isError) return new Text(theme.fg("error", "✗ ") + rawText(result), 0, 0); - return new Text(theme.fg("success", "✔ ") + rawText(result), 0, 0); - }, - }); - - // ── bmo_comment ─────────────────────────────────────────────────────────── - pi.registerTool({ - name: "bmo_comment", - label: "bmo comment", - description: "Add a comment to an issue, or list all comments on an issue.", - promptSnippet: "Add or list comments on a bmo issue", - parameters: Type.Object({ - action: StringEnum(["add", "list"] as const, { - description: "'add' to post a comment, 'list' to read existing comments", - }), - id: Type.Union([Type.String(), Type.Number()], { description: "Issue ID" }), - author: Type.Optional(Type.String({ description: "Comment author (required for action=add)" })), - body: Type.Optional(Type.String({ description: "Comment text (required for action=add)" })), - }), - async execute(_id, params, signal, _u, ctx) { - if (params.action === "add") { - if (!params.author || !params.body) { - return { - content: [{ type: "text", text: "Error: author and body are required when action=add" }], - details: {}, - isError: true, - }; - } - const { stdout, stderr, exitCode } = await runBmo( - ["comment", "add", String(params.id), "--author", params.author, "--body", params.body], - ctx.cwd, signal, - ); - return { content: toContent(stdout, stderr, exitCode), details: {} }; - } - const { stdout, stderr, exitCode } = await runBmo(["comment", "list", String(params.id)], ctx.cwd, signal); - return { content: toContent(stdout, stderr, exitCode), details: {} }; - }, - renderCall(args, theme) { - const action = String(args.action); - const suffix = action === "add" && args.author - ? " " + theme.fg("dim", `by ${args.author}`) - : ""; - return new Text( - theme.fg("toolTitle", theme.bold("bmo comment")) + " " + - theme.fg("accent", action) + " " + - theme.fg("dim", `BMO-${args.id}`) + suffix, - 0, 0, - ); - }, - renderResult(result, _opts, theme) { - if (result.isError) return new Text(theme.fg("error", "✗ ") + rawText(result), 0, 0); - - const data = parseResult(result); - // add action: plain text response - if (!data) return new Text(theme.fg("success", "✓ ") + rawText(result), 0, 0); - if (!data.ok) return new Text(theme.fg("error", data.error ?? rawText(result)), 0, 0); - - // list action: structured comment list - if (Array.isArray(data.data)) { - const comments = data.data as Array<{ author: string; body: string; created_at: string }>; - if (comments.length === 0) return new Text(theme.fg("dim", "No comments."), 0, 0); - const container = new Container(); - container.addChild(new Text(theme.fg("muted", `${comments.length} comment${comments.length !== 1 ? "s" : ""}:`), 0, 0)); - for (const c of comments) { - container.addChild(new Spacer(1)); - container.addChild(new Text(theme.fg("accent", c.author), 0, 0)); - for (const line of c.body.split("\n")) { - container.addChild(new Text(theme.fg("text", ` ${line}`), 0, 0)); - } - } - return container; - } - - return new Text(theme.fg("success", "✓ ") + (data.message ?? rawText(result)), 0, 0); - }, - }); - - // ── bmo_link ────────────────────────────────────────────────────────────── - pi.registerTool({ - name: "bmo_link", - label: "bmo link", - description: - "Add a dependency link between two issues. " + - "'blocks' means from_id must complete before to_id. " + - "WARNING: from_id and to_id must differ — bmo enforces a DAG and will reject cycles.", - promptSnippet: "Link two bmo issues with a dependency relation", - promptGuidelines: [ - "Verify from_id ≠ to_id before calling bmo_link — an issue cannot depend on itself.", - ], - parameters: Type.Object({ - from_id: Type.Union([Type.String(), Type.Number()], { description: "Source issue ID" }), - relation: StringEnum(["blocks", "blocked-by", "relates-to"] as const, { - description: "Relation type", - }), - to_id: Type.Union([Type.String(), Type.Number()], { - description: "Target issue ID (must differ from from_id)", - }), - }), - async execute(_id, params, signal, _u, ctx) { - if (String(params.from_id) === String(params.to_id)) { - return { - content: [{ type: "text", text: "Error: from_id and to_id must differ — an issue cannot depend on itself." }], - details: {}, - isError: true, - }; - } - const { stdout, stderr, exitCode } = await runBmo( - ["link", "add", String(params.from_id), params.relation, String(params.to_id)], - ctx.cwd, signal, - ); - return { content: toContent(stdout, stderr, exitCode), details: {} }; - }, - renderCall(args, theme) { - return new Text( - theme.fg("toolTitle", theme.bold("bmo link")) + " " + - theme.fg("accent", `BMO-${args.from_id}`) + " " + - theme.fg("muted", String(args.relation)) + " " + - theme.fg("accent", `BMO-${args.to_id}`), - 0, 0, - ); - }, - renderResult(result, _opts, theme) { - if (result.isError) return new Text(theme.fg("error", "✗ ") + rawText(result), 0, 0); - return new Text(theme.fg("success", "✓ ") + rawText(result), 0, 0); - }, - }); - - // ── bmo_stats ───────────────────────────────────────────────────────────── - pi.registerTool({ - name: "bmo_stats", - label: "bmo stats", - description: "Show issue counts broken down by status and priority.", - promptSnippet: "Get bmo issue statistics (counts by status and priority)", - parameters: Type.Object({}), - async execute(_id, _p, signal, _u, ctx) { - const { stdout, stderr, exitCode } = await runBmo(["stats"], ctx.cwd, signal); - return { content: toContent(stdout, stderr, exitCode), details: {} }; - }, - renderCall(_args, theme) { - return new Text(theme.fg("toolTitle", theme.bold("bmo stats")), 0, 0); - }, - renderResult(result, _opts, theme) { - const data = parseResult(result); - if (!data) return new Text(theme.fg("error", rawText(result)), 0, 0); - if (!data.ok) return new Text(theme.fg("error", data.error ?? rawText(result)), 0, 0); - - const stats = data.data as { - total: number; - by_status: Record; - by_priority: Record; - }; - - const container = new Container(); - container.addChild(new Text(theme.fg("muted", `Total: ${theme.fg("text", String(stats.total))}`), 0, 0)); - - if (stats.by_status) { - container.addChild(new Spacer(1)); - container.addChild(new Text(theme.fg("muted", "By status:"), 0, 0)); - for (const [status, count] of Object.entries(stats.by_status)) { - if (count === 0) continue; - const color = STATUS_COLOR[status] ?? "muted"; - container.addChild(new Text( - ` ${theme.fg(color, status.padEnd(14))} ${theme.fg("text", String(count))}`, - 0, 0, - )); - } - } - - if (stats.by_priority) { - container.addChild(new Spacer(1)); - container.addChild(new Text(theme.fg("muted", "By priority:"), 0, 0)); - for (const [priority, count] of Object.entries(stats.by_priority)) { - if (count === 0) continue; - const color = PRIORITY_COLOR[priority] ?? "dim"; - container.addChild(new Text( - ` ${theme.fg(color, priority.padEnd(10))} ${theme.fg("text", String(count))}`, - 0, 0, - )); - } - } - - return container; - }, - }); - - // ── bmo_reopen ──────────────────────────────────────────────────────────── - pi.registerTool({ - name: "bmo_reopen", - label: "bmo reopen", - description: "Reopen a closed issue, setting its status back to 'todo'.", - promptSnippet: "Reopen a closed bmo issue (sets status back to todo)", - parameters: Type.Object({ - id: Type.Union([Type.String(), Type.Number()], { description: "Issue ID" }), - }), - async execute(_id, params, signal, _u, ctx) { - const { stdout, stderr, exitCode } = await runBmo(["reopen", String(params.id)], ctx.cwd, signal); - return { content: toContent(stdout, stderr, exitCode), details: {} }; - }, - renderCall(args, theme) { - return new Text( - theme.fg("toolTitle", theme.bold("bmo reopen")) + " " + theme.fg("accent", `BMO-${args.id}`), - 0, 0, - ); - }, - renderResult(result, _opts, theme) { - if (result.isError) return new Text(theme.fg("error", "✗ ") + rawText(result), 0, 0); - return new Text(theme.fg("accent", "↺ ") + rawText(result), 0, 0); - }, - }); - - // ── bmo_file ────────────────────────────────────────────────────────────── - pi.registerTool({ - name: "bmo_file", - label: "bmo file", - description: - "Attach a file path to an issue. Call once per file immediately after bmo_create. " + - "File attachments drive parallel-agent collision detection.", - promptSnippet: "Attach a file path to a bmo issue (call once per file)", - parameters: Type.Object({ - id: Type.Union([Type.String(), Type.Number()], { description: "Issue ID" }), - path: Type.String({ description: "File path to attach (relative to project root)" }), - }), - async execute(_id, params, signal, _u, ctx) { - const { stdout, stderr, exitCode } = await runBmo( - ["file", "add", String(params.id), params.path], - ctx.cwd, signal, - ); - return { content: toContent(stdout, stderr, exitCode), details: {} }; - }, - renderCall(args, theme) { - return new Text( - theme.fg("toolTitle", theme.bold("bmo file")) + " " + - theme.fg("accent", `BMO-${args.id}`) + " " + - theme.fg("dim", String(args.path)), - 0, 0, - ); - }, - renderResult(result, _opts, theme) { - if (result.isError) return new Text(theme.fg("error", "✗ ") + rawText(result), 0, 0); - return new Text(theme.fg("success", "✓ ") + rawText(result), 0, 0); - }, - }); -} diff --git a/pi-extension/index.ts b/pi-extension/index.ts deleted file mode 100644 index 0750283..0000000 --- a/pi-extension/index.ts +++ /dev/null @@ -1,257 +0,0 @@ -/** - * pi-bmo extension - * - * Integrates bmo (local issue tracker) natively into pi-code: - * - bmo_* tools: typed wrappers for every bmo CLI command - * - Context injection: injects board state into the system prompt - * when a .bmo/issues.db is found in the project - * - Status widget: compact board counts above the pi editor - * - Slash commands: /bmo-board, /bmo-ls, /bmo-show, /bmo-next, - * /bmo-plan, /bmo-stats, /bmo-init, /bmo-widget - * - * Install (user-global): - * pi install git:github.com/erewok/bmo - * - * Or add to ~/.pi/agent/settings.json for local dev: - * { "extensions": ["/path/to/bmo/pi-extension"] } - */ - -import { execFile } from "node:child_process"; -import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"; -import { registerBmoTools, hasBmoProject, getBoardCounts, getNextSummary } from "./bmo-tools.ts"; - -export default function (pi: ExtensionAPI): void { - // ── bmo_* tools ──────────────────────────────────────────────────────── - registerBmoTools(pi); - - // ── Context injection ────────────────────────────────────────────────── - // When an agent turn starts and a bmo project is active, silently inject - // the current board summary + next issues into the system prompt context. - pi.on("before_agent_start", async (_event, ctx) => { - if (!hasBmoProject(ctx.cwd)) return; - - const [counts, next] = await Promise.all([ - getBoardCounts(ctx.cwd), - getNextSummary(ctx.cwd), - ]); - if (!counts) return; - - const total = Object.values(counts).reduce((a, b) => a + b, 0); - if (total === 0 && !next) return; - - const statusLine = [ - `backlog:${counts.backlog}`, - `todo:${counts.todo}`, - `in-progress:${counts.in_progress}`, - `review:${counts.review}`, - `done:${counts.done}`, - ].join(" "); - - let content = `## Active bmo Project\n\nBoard: ${statusLine}`; - if (next) content += `\n\nNext work-ready:\n${next}`; - - return { - message: { - customType: "bmo-context", - content, - display: false, - }, - }; - }); - - // ── Status widget ────────────────────────────────────────────────────── - // Shown above the editor when a bmo project is active. - // Toggle with /bmo-widget. Preference persists across sessions. - - let widgetEnabled = true; - - // Restore saved preference from session entries on startup. - pi.on("session_start", async (event, ctx) => { - if (event.reason === "startup" || event.reason === "resume") { - for (const entry of ctx.sessionManager.getEntries()) { - if (entry.type === "custom" && entry.customType === "bmo-widget-pref") { - widgetEnabled = (entry as any).data?.enabled ?? true; - } - } - } - await refreshWidget(ctx.cwd, ctx.ui); - }); - - pi.on("agent_end", async (_event, ctx) => { - await refreshWidget(ctx.cwd, ctx.ui); - }); - - async function refreshWidget( - cwd: string, - ui: { setWidget(id: string, content: any): void }, - ): Promise { - if (!widgetEnabled || !hasBmoProject(cwd)) { - ui.setWidget("bmo", undefined); - return; - } - - const counts = await getBoardCounts(cwd); - if (!counts) { - ui.setWidget("bmo", undefined); - return; - } - - const total = Object.values(counts).reduce((a, b) => a + b, 0); - - if (total === 0) { - // Show a minimal presence so users know the widget is active. - ui.setWidget("bmo", ["bmo (no issues)"]); - return; - } - - // Only show non-zero counts — keeps the line compact. - const parts: string[] = ["bmo"]; - if (counts.backlog) parts.push(`${counts.backlog} backlog`); - if (counts.todo) parts.push(`${counts.todo} todo`); - if (counts.in_progress) parts.push(`${counts.in_progress} in-progress`); - if (counts.review) parts.push(`${counts.review} review`); - if (counts.done) parts.push(`${counts.done} done`); - - ui.setWidget("bmo", [parts.join(" ")]); - } - - // ── Slash commands ───────────────────────────────────────────────────── - - pi.registerCommand("bmo-widget", { - description: "Toggle the bmo board status widget on or off", - handler: async (_args, ctx) => { - widgetEnabled = !widgetEnabled; - pi.appendEntry("bmo-widget-pref", { enabled: widgetEnabled }); - if (widgetEnabled) { - await refreshWidget(ctx.cwd, ctx.ui); - ctx.ui.notify("bmo widget enabled", "info"); - } else { - ctx.ui.setWidget("bmo", undefined); - ctx.ui.notify("bmo widget disabled", "info"); - } - }, - }); - - pi.registerCommand("bmo-init", { - description: "Initialize a bmo project in the current directory", - handler: async (_args, ctx) => { - const { stdout, stderr } = await bmoExec(["init"], ctx.cwd); - ctx.ui.notify(stdout.trim() || stderr.trim() || "bmo initialized.", "info"); - await refreshWidget(ctx.cwd, ctx.ui); - }, - }); - - pi.registerCommand("bmo-ls", { - description: "List bmo issues. Optionally filter by status: /bmo-ls in-progress", - handler: async (args, ctx) => { - if (!hasBmoProject(ctx.cwd)) { - ctx.ui.notify("No bmo project found. Run /bmo-init first.", "warning"); - return; - } - const cliArgs = ["list"]; - const status = args?.trim(); - if (status) cliArgs.push("--status", status); - const { stdout, stderr } = await bmoExec(cliArgs, ctx.cwd); - pi.sendMessage( - { customType: "bmo-list", content: stripAnsi(stdout || stderr || "(no output)").trim(), display: true }, - { triggerTurn: false }, - ); - }, - }); - - pi.registerCommand("bmo-show", { - description: "Show a bmo issue with comments and relations: /bmo-show BMO-7", - handler: async (args, ctx) => { - if (!hasBmoProject(ctx.cwd)) { - ctx.ui.notify("No bmo project found. Run /bmo-init first.", "warning"); - return; - } - const id = args?.trim(); - if (!id) { - ctx.ui.notify("Usage: /bmo-show (e.g. /bmo-show BMO-7)", "warning"); - return; - } - const { stdout, stderr } = await bmoExec(["show", id], ctx.cwd); - pi.sendMessage( - { customType: "bmo-show", content: stripAnsi(stdout || stderr || "(no output)").trim(), display: true }, - { triggerTurn: false }, - ); - }, - }); - - pi.registerCommand("bmo-stats", { - description: "Show bmo issue counts by status and priority", - handler: async (_args, ctx) => { - if (!hasBmoProject(ctx.cwd)) { - ctx.ui.notify("No bmo project found. Run /bmo-init first.", "warning"); - return; - } - const { stdout, stderr } = await bmoExec(["stats"], ctx.cwd); - pi.sendMessage( - { customType: "bmo-stats", content: stripAnsi(stdout || stderr || "(no output)").trim(), display: true }, - { triggerTurn: false }, - ); - }, - }); - - pi.registerCommand("bmo-board", { - description: "Show the current bmo kanban board", - handler: async (_args, ctx) => { - if (!hasBmoProject(ctx.cwd)) { - ctx.ui.notify("No bmo project found. Run /bmo-init first.", "warning"); - return; - } - const { stdout, stderr } = await bmoExec(["board"], ctx.cwd); - pi.sendMessage( - { customType: "bmo-board", content: stripAnsi(stdout || stderr || "(no output)").trim(), display: true }, - { triggerTurn: false }, - ); - }, - }); - - pi.registerCommand("bmo-next", { - description: "Show next work-ready bmo issues", - handler: async (_args, ctx) => { - if (!hasBmoProject(ctx.cwd)) { - ctx.ui.notify("No bmo project found. Run /bmo-init first.", "warning"); - return; - } - const { stdout, stderr } = await bmoExec(["next"], ctx.cwd); - pi.sendMessage( - { customType: "bmo-next", content: stripAnsi(stdout || stderr || "(no output)").trim(), display: true }, - { triggerTurn: false }, - ); - }, - }); - - pi.registerCommand("bmo-plan", { - description: "Show the phased bmo execution plan", - handler: async (_args, ctx) => { - if (!hasBmoProject(ctx.cwd)) { - ctx.ui.notify("No bmo project found. Run /bmo-init first.", "warning"); - return; - } - const { stdout, stderr } = await bmoExec(["plan"], ctx.cwd); - pi.sendMessage( - { customType: "bmo-plan", content: stripAnsi(stdout || stderr || "(no output)").trim(), display: true }, - { triggerTurn: false }, - ); - }, - }); -} - -// --------------------------------------------------------------------------- -// Helpers -// --------------------------------------------------------------------------- - -function bmoExec(args: string[], cwd: string): Promise<{ stdout: string; stderr: string }> { - return new Promise((resolve) => { - execFile("bmo", args, { cwd }, (_err, stdout, stderr) => resolve({ stdout, stderr })); - }); -} - -/** Strip ANSI escape codes so raw bmo CLI output displays cleanly in chat. */ -function stripAnsi(str: string): string { - // eslint-disable-next-line no-control-regex - return str.replace(/\x1b\[[0-9;]*[mGKHF]/g, ""); -} diff --git a/pi-extension/package.json b/pi-extension/package.json deleted file mode 100644 index cf8666d..0000000 --- a/pi-extension/package.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "name": "pi-bmo", - "version": "0.1.0", - "description": "Pi-code extension for bmo — native bmo_* tools, board context injection, status widget, and slash commands", - "keywords": ["pi-package", "bmo", "issue-tracker"], - "license": "Apache-2.0", - "repository": { - "type": "git", - "url": "https://github.com/erewok/bmo" - }, - "pi": { - "extensions": ["./index.ts"] - }, - "peerDependencies": { - "@earendil-works/pi-coding-agent": "*", - "@earendil-works/pi-ai": "*", - "@earendil-works/pi-tui": "*", - "typebox": "*" - } -} From 05692eb9e9318d56ca7101b16441e2b985cff5ef Mon Sep 17 00:00:00 2001 From: Erik Aker Date: Thu, 21 May 2026 11:10:47 -0700 Subject: [PATCH 3/3] simple better fast er more --- README.md | 7 +++---- docs/pi-extension.md | 2 +- 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 90c8b22..2fbb23d 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ For an example of agents and skills that have been instructed to use `bmo` check `bmo` was inspired by and adapted from [docket](https://github.com/ALT-F4-LLC/docket), an issue tracker for AI agents written by **ALT-F4-LLC**. -In addition, the code in this repository was written by [Claude Code](https://claude.ai/claude-code), Anthropic's AI coding assistant. The repo owner directed this work. +In addition, the code in this repository was written by [Claude Code](https://claude.ai/claude-code). The repo owner directed this work. ## Installation @@ -55,9 +55,8 @@ pi install git:github.com/erewok/pi-bmo ``` A companion pi-code extension, [bmo-dev-team](https://github.com/erewok/bmo-agent-setup), adds a -full AI software development team (staff-engineer, project-manager, senior-engineer, -qa-engineer, ux-designer) that plans and executes work through bmo. Installing it also -pulls in `pi-bmo` automatically: +full software development team of agents (staff-engineer, project-manager, senior-engineer, +qa-engineer, ux-designer) that plans and executes work through `bmo`. ```bash pi install git:github.com/erewok/bmo-agent-setup diff --git a/docs/pi-extension.md b/docs/pi-extension.md index e6fe9a7..32a27ca 100644 --- a/docs/pi-extension.md +++ b/docs/pi-extension.md @@ -57,7 +57,7 @@ pi -e git:github.com/erewok/pi-bmo See the [pi-bmo repo](https://github.com/erewok/pi-bmo) for full installation, update, and local development instructions. -``` + ## Dev-Team Extension