feat: pi extension

This commit is contained in:
Dustin Byrne
2026-03-28 22:14:40 -04:00
parent 51c4afdcae
commit 2d13b82d7c
12 changed files with 915 additions and 216 deletions

View File

@@ -1,78 +0,0 @@
---
name: kb-board
description: Start and manage the kb dashboard web UI and AI engine. Use when asked to start the board, run the dashboard, enable the AI engine, or configure kb settings.
---
# kb board
## Start the dashboard
```bash
kb dashboard
```
Opens the kanban board at http://localhost:4040 and starts the AI engine.
The AI engine provides:
- **Triage processor** — auto-specifies tasks in the triage column
- **Scheduler** — moves todo tasks to in-progress when dependencies are met
- **Executor** — runs tasks in git worktrees via AI agents
- **Auto-merge** — squash-merges completed tasks to main
- **Cross-model review** — independent reviewer agent checks work at step boundaries
Options:
- `--port <N>` or `-p <N>` — custom port (default: 4040)
- `--no-open` — don't open the browser automatically
### Development mode
Run in two terminals:
```bash
# Terminal 1: start the server + engine
kb dashboard
# Terminal 2: watch-rebuild the React dashboard UI
pnpm dev:ui
```
## Configuration
Settings are in `.kb/config.json`:
```json
{
"nextId": 27,
"settings": {
"maxConcurrent": 4,
"maxWorktrees": 4,
"pollIntervalMs": 15000,
"autoMerge": true
}
}
```
| Setting | Description | Default |
|---------|-------------|---------|
| `maxConcurrent` | Max tasks executing simultaneously | 2 |
| `maxWorktrees` | Max git worktrees | 4 |
| `pollIntervalMs` | Scheduler/triage poll interval | 15000 |
| `autoMerge` | Auto-merge tasks when they reach in-review | true |
## Task storage
Tasks live in `.kb/tasks/`:
```
.kb/
├── config.json
└── tasks/
└── KB-001/
├── task.json # Metadata, steps, log
└── PROMPT.md # Task specification
```
## Prerequisites
The AI engine requires [pi](https://github.com/badlogic/pi-mono) with
configured API keys. Run `pi` first to set up authentication.

View File

@@ -1,128 +0,0 @@
---
name: kb-task
description: Create, manage, and track tasks on the kb board. Use when asked to create a task, file a bug, report an issue, check task status, update progress, or interact with the kb task board in any way.
---
# kb task
kb is an AI-orchestrated task board. Tasks flow through columns:
**triage → todo → in-progress → in-review → done**
## Commands
### Create a task
```bash
kb task create "description of what needs to be done"
kb task create "button is misaligned" --attach screenshot.png
kb task create "server crash" --attach error.log --attach trace.txt
kb task create "implement caching" --depends KB-042
kb task create "deploy to prod" --depends KB-042 --depends KB-043
```
Creates a task in **triage**. The AI triage agent will specify it into a full
PROMPT.md with steps, file scope, review level, and acceptance criteria, then
move it to **todo**.
Options:
- `--attach <file>` — attach files (images, logs, configs). Repeatable.
Images are sent to the triage agent for visual context.
Files are stored in `.kb/tasks/KB-XXX/attachments/`.
- `--depends <id>` — declare a dependency on another task. Repeatable.
The scheduler won't start this task until all dependencies are done.
Tips:
- Be descriptive — the triage agent uses this to write the spec
- Include the problem AND desired outcome when possible
- For bugs, describe the current behavior and expected behavior
- Attach screenshots for UI bugs — the AI can see them
- No need to specify how to fix it — the triage agent figures that out
### List tasks
```bash
kb task list
```
Shows all tasks grouped by column with IDs and descriptions.
### Show task details
```bash
kb task show KB-001
```
Shows full task info: steps, progress, log entries, dependencies.
### Move a task
```bash
kb task move KB-001 <column>
```
Columns: `triage`, `todo`, `in-progress`, `in-review`, `done`
Transitions are validated:
- triage → todo
- todo → in-progress, triage
- in-progress → in-review
- in-review → done, in-progress
- done → (none)
### Update step status
```bash
kb task update KB-001 <step-number> <status>
```
Status: `pending`, `in-progress`, `done`, `skipped`
Steps are 0-indexed and auto-parsed from the PROMPT.md headings.
### Log an entry
```bash
kb task log KB-001 "what happened"
```
Adds a timestamped log entry visible on the task card.
### Merge a completed task
```bash
kb task merge KB-001
```
Squash-merges the task's branch into main with an AI-written commit message.
Only works for tasks in **in-review**. Resolves conflicts via AI if needed.
Cleans up the worktree and branch after merge.
## Workflow
1. **Create**`kb task create "description"` → goes to triage
2. **Triage** — AI agent reads the codebase, writes a PROMPT.md spec, moves to todo
3. **Schedule** — Scheduler moves to in-progress when deps are met and concurrency allows
4. **Execute** — AI agent works the task in a git worktree, reports progress via tools
5. **Review** — Cross-model reviewer checks plan/code at step boundaries
6. **Merge**`kb task merge KB-001` squash-merges to main
## Filing good tasks
A task can be anything from a rough idea to a detailed spec:
```bash
# Rough — triage agent will flesh it out
kb task create "the login page is slow"
# Specific — triage agent will structure it
kb task create "Add rate limiting to POST /api/tasks. Use a token bucket algorithm with 100 req/min per IP. Return 429 with Retry-After header when exceeded."
# Bug report with screenshot
kb task create "button is misaligned on mobile" --attach screenshot.png
# Bug report with logs
kb task create "server crashes on startup" --attach crash.log
# Task with dependencies — won't start until KB-012 and KB-013 are done
kb task create "integrate search API into frontend" --depends KB-012 --depends KB-013
```

View File

@@ -0,0 +1,5 @@
---
"@dustinbyrne/kb": minor
---
Add pi extension. Installing `@dustinbyrne/kb` via `pi install` now provides native tools (`kb_task_create`, `kb_task_list`, `kb_task_show`, `kb_task_attach`, `kb_task_pause`, `kb_task_unpause`) and a `/kb` command to start the dashboard and AI engine from within a pi session.

View File

@@ -0,0 +1,5 @@
---
"@dustinbyrne/kb": patch
---
Agents now declare dependencies when creating multiple related tasks during execution

33
.pi/agents/supervisor.md Normal file
View File

@@ -0,0 +1,33 @@
---
name: supervisor
# tools: read,write,edit,bash,grep,find,ls
# model:
# standalone: true
---
<!-- ═══════════════════════════════════════════════════════════════════
Project-Specific Supervisor Guidance
This file is COMPOSED with the base supervisor prompt shipped in the
taskplane package. Your content here is appended after the base prompt.
The base prompt (maintained by taskplane) handles:
- Supervisor identity and standing orders
- Recovery action classification and autonomy levels
- Audit trail format and rules
- Batch monitoring, failure handling, operator communication
- Orchestrator tool reference (orch_status, orch_pause, etc.)
- Startup checklist and operational knowledge
Add project-specific supervisor rules below. Common examples:
- Run linter before integration ("always run `npm run lint` after merge")
- CI dashboard URL for failure triage
- PR template or label conventions
- Project-specific recovery procedures
- Team notification preferences (Slack, etc.)
- Custom health check commands
To override frontmatter values (tools, model), uncomment and edit above.
To use this file as a FULLY STANDALONE prompt (ignoring the base),
uncomment `standalone: true` above and write the complete prompt below.
═══════════════════════════════════════════════════════════════════ -->

9
.pi/taskplane.json Normal file
View File

@@ -0,0 +1,9 @@
{
"migrations": {
"applied": {
"add-supervisor-local-template-v1": {
"appliedAt": "2026-03-29T00:55:05.137Z"
}
}
}
}

View File

@@ -2,7 +2,7 @@
## Finalizing changes
When making changes that affect the published `@dustinbyrne/kb` (cli) package, create a changeset file:
When making changes that affect published packages, create a changeset file:
```bash
cat > .changeset/<short-description>.md << 'EOF'
@@ -17,19 +17,19 @@ EOF
Bump types:
- **patch**: bug fixes, internal changes
- **minor**: new features, new CLI commands
- **minor**: new features, new CLI commands, new tools
- **major**: breaking changes
Include the changeset file in the same commit as the code change. The filename should be a short kebab-case description (e.g. `fix-merge-conflict.md`, `add-retry-button.md`).
Only create changesets for changes that affect the published `@dustinbyrne/kb` package — user-facing features, bug fixes, CLI changes. Do NOT create changesets for internal docs (AGENTS.md, README), CI config, or refactors that don't change behavior.
Only create changesets for changes that affect the published `@dustinbyrne/kb` package — user-facing features, bug fixes, CLI changes, tool changes. Do NOT create changesets for internal docs (AGENTS.md, README), CI config, or refactors that don't change behavior.
## Package Structure
- `@kb/core` — domain model, task store (private, not published)
- `@kb/dashboard` — web UI + API server (private, not published)
- `@kb/engine` — AI agents: triage, executor, reviewer, merger, scheduler (private, not published)
- `@dustinbyrne/kb` — CLI entry point (published to npm)
- `@dustinbyrne/kb` — CLI + pi extension (published to npm)
Only `@dustinbyrne/kb` is published. The others are internal workspace packages.
@@ -42,14 +42,19 @@ pnpm build # build all packages
Tests are required. Typechecks and manual verification are not substitutes for real tests with assertions.
## CLI-to-Skills Sync
## Pi Extension (`packages/cli/src/extension.ts`)
When CLI commands, flags, or workflows change in `@dustinbyrne/kb`, update the corresponding skill docs:
The pi extension provides tools and a `/kb` command for interacting with kb from within a pi session. It ships as part of `@dustinbyrne/kb` — one `pi install` gives you both the CLI and the extension.
- `.agents/skills/kb-task/SKILL.md` — task creation, management, and tracking commands
- `.agents/skills/kb-board/SKILL.md` — dashboard startup, AI engine, and configuration commands
Update it when:
These skill files are what AI agents read to understand how to use the CLI. Stale skill docs cause agent errors. Always check them when modifying CLI behavior.
- **CLI commands change** — if `kb task create`, `kb task list`, `kb task show`, `kb task attach`, `kb task pause`, or `kb task unpause` change their behavior, flags, or output, update the corresponding tool in `packages/cli/src/extension.ts`.
- **Task store API changes** — the extension calls `TaskStore` directly (`createTask`, `listTasks`, `getTask`, `addAttachment`, `pauseTask`). If these methods change signature or behavior, update the extension.
- **New user-facing features** — if a new CLI command is added that the chat agent should be able to use (task creation, status checking, automation control), add a tool for it.
**Don't** add tools for engine-internal operations (move, step updates, logging, merge) — those are handled by the engine's own agents.
The extension has no skills — tool descriptions, `promptSnippet`, and `promptGuidelines` give the LLM everything it needs.
## Git

View File

@@ -2,9 +2,16 @@
"name": "@dustinbyrne/kb",
"version": "0.3.1",
"type": "module",
"keywords": ["pi-package"],
"bin": {
"kb": "./dist/bin.js"
},
"pi": {
"extensions": ["./dist/extension.js"]
},
"publishConfig": {
"access": "public"
},
"files": [
"dist/**/*.js",
"dist/**/*.d.ts",
@@ -27,10 +34,21 @@
"express": "^5.1.0",
"multer": "^2.1.1"
},
"peerDependencies": {
"@mariozechner/pi-ai": "*",
"@mariozechner/pi-coding-agent": "*",
"@sinclair/typebox": "*"
},
"peerDependenciesMeta": {
"@mariozechner/pi-ai": { "optional": true },
"@mariozechner/pi-coding-agent": { "optional": true },
"@sinclair/typebox": { "optional": true }
},
"devDependencies": {
"@kb/core": "workspace:*",
"@kb/dashboard": "workspace:*",
"@kb/engine": "workspace:*",
"@sinclair/typebox": "^0.34.0",
"tsup": "^8.5.1",
"tsx": "^4.19.0",
"typescript": "^5.7.0",

View File

@@ -0,0 +1,384 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { mkdtemp, rm, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import kbExtension from "../extension.js";
import { TaskStore } from "@kb/core";
// ── Mock ExtensionAPI that captures registrations ──────────────────
interface RegisteredTool {
name: string;
label: string;
description: string;
execute: (
toolCallId: string,
params: any,
signal: AbortSignal | undefined,
onUpdate: ((update: any) => void) | undefined,
ctx: any,
) => Promise<any>;
}
interface RegisteredCommand {
description: string;
handler: (args: string, ctx: any) => Promise<void>;
}
function createMockAPI() {
const tools = new Map<string, RegisteredTool>();
const commands = new Map<string, RegisteredCommand>();
const events = new Map<string, Function>();
const api = {
registerTool(def: any) {
tools.set(def.name, def);
},
registerCommand(name: string, def: any) {
commands.set(name, def);
},
registerShortcut: vi.fn(),
registerFlag: vi.fn(),
on(event: string, handler: Function) {
events.set(event, handler);
},
tools,
commands,
events,
};
return api as any;
}
function makeCtx(cwd: string) {
return { cwd } as any;
}
// ── Tests ──────────────────────────────────────────────────────────
describe("kb pi extension", () => {
let tmpDir: string;
let api: ReturnType<typeof createMockAPI>;
beforeEach(async () => {
tmpDir = await mkdtemp(join(tmpdir(), "kb-ext-test-"));
api = createMockAPI();
kbExtension(api);
});
afterEach(async () => {
await rm(tmpDir, { recursive: true, force: true });
});
describe("registration", () => {
it("registers all expected tools", () => {
const expected = [
"kb_task_create",
"kb_task_list",
"kb_task_show",
"kb_task_attach",
"kb_task_pause",
"kb_task_unpause",
];
for (const name of expected) {
expect(api.tools.has(name), `missing tool: ${name}`).toBe(true);
}
expect(api.tools.size).toBe(expected.length);
});
it("does not register engine-internal tools", () => {
expect(api.tools.has("kb_task_move")).toBe(false);
expect(api.tools.has("kb_task_update_step")).toBe(false);
expect(api.tools.has("kb_task_log")).toBe(false);
expect(api.tools.has("kb_task_merge")).toBe(false);
});
it("registers the /kb command", () => {
expect(api.commands.has("kb")).toBe(true);
expect(api.commands.get("kb")!.description).toContain("dashboard");
});
it("registers session_shutdown listener", () => {
expect(api.events.has("session_shutdown")).toBe(true);
});
});
describe("kb_task_create", () => {
it("creates a task and returns its ID", async () => {
const tool = api.tools.get("kb_task_create")!;
const result = await tool.execute(
"call-1",
{ description: "Fix the login button" },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(result.content[0].text).toContain("KB-001");
expect(result.content[0].text).toContain("Fix the login button");
expect(result.content[0].text).toContain("triage");
expect(result.details.taskId).toBe("KB-001");
expect(result.details.column).toBe("triage");
});
it("creates a task with dependencies", async () => {
const tool = api.tools.get("kb_task_create")!;
await tool.execute(
"call-1",
{ description: "First task" },
undefined,
undefined,
makeCtx(tmpDir),
);
const result = await tool.execute(
"call-2",
{ description: "Second task", depends: ["KB-001"] },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(result.details.taskId).toBe("KB-002");
expect(result.details.dependencies).toEqual(["KB-001"]);
expect(result.content[0].text).toContain("Dependencies: KB-001");
});
});
describe("kb_task_list", () => {
it("returns empty message when no tasks", async () => {
const tool = api.tools.get("kb_task_list")!;
const result = await tool.execute(
"call-1",
{},
undefined,
undefined,
makeCtx(tmpDir),
);
expect(result.content[0].text).toBe("No tasks yet.");
expect(result.details.count).toBe(0);
});
it("lists tasks grouped by column", async () => {
const createTool = api.tools.get("kb_task_create")!;
await createTool.execute(
"c1",
{ description: "Task A" },
undefined,
undefined,
makeCtx(tmpDir),
);
await createTool.execute(
"c2",
{ description: "Task B" },
undefined,
undefined,
makeCtx(tmpDir),
);
const listTool = api.tools.get("kb_task_list")!;
const result = await listTool.execute(
"call-1",
{},
undefined,
undefined,
makeCtx(tmpDir),
);
expect(result.content[0].text).toContain("Triage (2)");
expect(result.content[0].text).toContain("KB-001");
expect(result.content[0].text).toContain("KB-002");
expect(result.details.count).toBe(2);
});
it("filters by column", async () => {
const createTool = api.tools.get("kb_task_create")!;
await createTool.execute(
"c1",
{ description: "Task A" },
undefined,
undefined,
makeCtx(tmpDir),
);
const listTool = api.tools.get("kb_task_list")!;
const triageResult = await listTool.execute(
"call-1",
{ column: "triage" },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(triageResult.content[0].text).toContain("Triage (1)");
expect(triageResult.content[0].text).toContain("KB-001");
const todoResult = await listTool.execute(
"call-2",
{ column: "todo" },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(todoResult.content[0].text).toBe("");
});
it("respects per-column limit", async () => {
const createTool = api.tools.get("kb_task_create")!;
for (let i = 0; i < 5; i++) {
await createTool.execute(
`c${i}`,
{ description: `Task ${i}` },
undefined,
undefined,
makeCtx(tmpDir),
);
}
const listTool = api.tools.get("kb_task_list")!;
const result = await listTool.execute(
"call-1",
{ limit: 2 },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(result.content[0].text).toContain("Triage (5)");
expect(result.content[0].text).toContain("KB-001");
expect(result.content[0].text).toContain("KB-002");
expect(result.content[0].text).not.toContain("KB-003");
expect(result.content[0].text).toContain("... and 3 more");
});
});
describe("kb_task_show", () => {
it("shows task details", async () => {
const createTool = api.tools.get("kb_task_create")!;
await createTool.execute(
"c1",
{ description: "Implement caching layer" },
undefined,
undefined,
makeCtx(tmpDir),
);
const showTool = api.tools.get("kb_task_show")!;
const result = await showTool.execute(
"call-1",
{ id: "KB-001" },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(result.content[0].text).toContain("KB-001");
expect(result.content[0].text).toContain("Implement caching layer");
expect(result.content[0].text).toContain("Triage");
expect(result.details.task).toBeDefined();
expect(result.details.task.id).toBe("KB-001");
});
});
describe("kb_task_attach", () => {
it("attaches a file to a task", async () => {
const createTool = api.tools.get("kb_task_create")!;
await createTool.execute(
"c1",
{ description: "A task" },
undefined,
undefined,
makeCtx(tmpDir),
);
const testFile = join(tmpDir, "test.txt");
await writeFile(testFile, "hello world");
const attachTool = api.tools.get("kb_task_attach")!;
const result = await attachTool.execute(
"call-1",
{ id: "KB-001", path: "test.txt" },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(result.content[0].text).toContain("Attached to KB-001");
expect(result.content[0].text).toContain("test.txt");
expect(result.details.attachment).toBeDefined();
expect(result.details.attachment.originalName).toBe("test.txt");
});
it("rejects unsupported file types", async () => {
const createTool = api.tools.get("kb_task_create")!;
await createTool.execute(
"c1",
{ description: "A task" },
undefined,
undefined,
makeCtx(tmpDir),
);
const testFile = join(tmpDir, "file.exe");
await writeFile(testFile, "binary");
const attachTool = api.tools.get("kb_task_attach")!;
await expect(
attachTool.execute(
"call-1",
{ id: "KB-001", path: "file.exe" },
undefined,
undefined,
makeCtx(tmpDir),
),
).rejects.toThrow("Unsupported file type");
});
});
describe("kb_task_pause / unpause", () => {
it("pauses and unpauses a task", async () => {
const createTool = api.tools.get("kb_task_create")!;
await createTool.execute(
"c1",
{ description: "A task" },
undefined,
undefined,
makeCtx(tmpDir),
);
const pauseTool = api.tools.get("kb_task_pause")!;
const pauseResult = await pauseTool.execute(
"call-1",
{ id: "KB-001" },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(pauseResult.content[0].text).toContain("Paused KB-001");
// Verify it's paused
const showTool = api.tools.get("kb_task_show")!;
const show = await showTool.execute(
"call-2",
{ id: "KB-001" },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(show.content[0].text).toContain("PAUSED");
// Unpause
const unpauseTool = api.tools.get("kb_task_unpause")!;
const unpauseResult = await unpauseTool.execute(
"call-3",
{ id: "KB-001" },
undefined,
undefined,
makeCtx(tmpDir),
);
expect(unpauseResult.content[0].text).toContain("Unpaused KB-001");
});
});
});

View File

@@ -0,0 +1,443 @@
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
import { Type, type Static } from "@sinclair/typebox";
import { StringEnum } from "@mariozechner/pi-ai";
import {
TaskStore,
COLUMNS,
COLUMN_LABELS,
type Column,
type Task,
} from "@kb/core";
import { resolve, basename, extname } from "node:path";
import { readFile } from "node:fs/promises";
import { spawn, type ChildProcess } from "node:child_process";
// ── Helpers ────────────────────────────────────────────────────────
const MIME_TYPES: Record<string, string> = {
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".webp": "image/webp",
".txt": "text/plain",
".log": "text/plain",
".json": "application/json",
".yaml": "text/yaml",
".yml": "text/yaml",
".toml": "text/x-toml",
".csv": "text/csv",
".xml": "application/xml",
};
/** Cache stores per cwd to avoid re-init on every tool call. */
const storeCache = new Map<string, TaskStore>();
async function getStore(cwd: string): Promise<TaskStore> {
const existing = storeCache.get(cwd);
if (existing) return existing;
const store = new TaskStore(cwd);
await store.init();
storeCache.set(cwd, store);
return store;
}
function formatTaskLine(t: Task): string {
const label =
t.title || t.description.slice(0, 60) + (t.description.length > 60 ? "…" : "");
const deps = t.dependencies.length ? ` [deps: ${t.dependencies.join(", ")}]` : "";
const paused = t.paused ? " (paused)" : "";
return `${t.id} ${label}${deps}${paused}`;
}
// ── Extension entry point ──────────────────────────────────────────
export default function kbExtension(pi: ExtensionAPI) {
// ── kb_task_create ───────────────────────────────────────────────
pi.registerTool({
name: "kb_task_create",
label: "KB: Create Task",
description:
"Create a new task on the kb task board. The task enters the triage column " +
"where the AI triage agent will specify it into a full prompt with steps, " +
"file scope, and acceptance criteria.",
promptSnippet: "Create a task on the kb AI-orchestrated task board",
promptGuidelines: [
"Use kb_task_create for task tracking — be descriptive so the triage agent can write a good spec.",
"Include the problem AND desired outcome. For bugs, describe current vs expected behavior.",
],
parameters: Type.Object({
description: Type.String({ description: "What needs to be done — be descriptive" }),
depends: Type.Optional(
Type.Array(Type.String(), {
description: "Task IDs this depends on (e.g. ['KB-001', 'KB-002'])",
}),
),
}),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const store = await getStore(ctx.cwd);
const task = await store.createTask({
description: params.description.trim(),
dependencies: params.depends,
});
const label =
task.description.length > 80
? task.description.slice(0, 80) + "…"
: task.description;
return {
content: [
{
type: "text",
text:
`Created ${task.id}: ${label}\n` +
`Column: triage\n` +
(task.dependencies.length
? `Dependencies: ${task.dependencies.join(", ")}\n`
: "") +
`Path: .kb/tasks/${task.id}/`,
},
],
details: { taskId: task.id, column: task.column, dependencies: task.dependencies },
};
},
});
// ── kb_task_list ─────────────────────────────────────────────────
pi.registerTool({
name: "kb_task_list",
label: "KB: List Tasks",
description: "List all tasks on the kb board, grouped by column.",
promptSnippet: "List all tasks on the kb board grouped by column",
parameters: Type.Object({
column: Type.Optional(
StringEnum([...COLUMNS] as unknown as string[], {
description: "Filter to a specific column",
}) as any,
),
limit: Type.Optional(
Type.Number({
description: "Max tasks to show per column (default: 10)",
}),
),
}),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const store = await getStore(ctx.cwd);
const tasks = await store.listTasks();
if (tasks.length === 0) {
return {
content: [{ type: "text", text: "No tasks yet." }],
details: { count: 0 },
};
}
const perColumn = params.limit ?? 10;
const lines: string[] = [];
for (const col of COLUMNS) {
if (params.column && params.column !== col) continue;
const colTasks = tasks.filter((t) => t.column === col);
if (colTasks.length === 0) continue;
lines.push(`${COLUMN_LABELS[col]} (${colTasks.length}):`);
const shown = colTasks.slice(0, perColumn);
for (const t of shown) {
lines.push(` ${formatTaskLine(t)}`);
}
const hidden = colTasks.length - shown.length;
if (hidden > 0) {
lines.push(` ... and ${hidden} more`);
}
lines.push("");
}
return {
content: [{ type: "text", text: lines.join("\n").trimEnd() }],
details: { count: tasks.length },
};
},
});
// ── kb_task_show ─────────────────────────────────────────────────
pi.registerTool({
name: "kb_task_show",
label: "KB: Show Task",
description: "Show full details for a task including steps, progress, and log entries.",
promptSnippet: "Show full details for a kb task",
parameters: Type.Object({
id: Type.String({ description: "Task ID (e.g. KB-001)" }),
}),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const store = await getStore(ctx.cwd);
const task = await store.getTask(params.id);
const lines: string[] = [];
lines.push(`${task.id}: ${task.title || task.description}`);
lines.push(
`Column: ${COLUMN_LABELS[task.column]}` +
(task.size ? ` · Size: ${task.size}` : "") +
(task.reviewLevel !== undefined ? ` · Review: ${task.reviewLevel}` : ""),
);
if (task.dependencies.length) {
lines.push(`Dependencies: ${task.dependencies.join(", ")}`);
}
if (task.paused) lines.push("Status: PAUSED");
lines.push("");
// Steps
if (task.steps.length > 0) {
const done = task.steps.filter((s) => s.status === "done").length;
lines.push(`Steps (${done}/${task.steps.length}):`);
for (let i = 0; i < task.steps.length; i++) {
const s = task.steps[i];
const icon =
s.status === "done"
? "✓"
: s.status === "in-progress"
? "▸"
: s.status === "skipped"
? ""
: " ";
const marker =
i === task.currentStep && s.status !== "done" ? " ◀" : "";
lines.push(` [${icon}] ${i}: ${s.name}${marker}`);
}
lines.push("");
}
// Prompt (truncated)
if (task.prompt) {
const promptPreview =
task.prompt.length > 500
? task.prompt.slice(0, 500) + "\n... (truncated)"
: task.prompt;
lines.push("Prompt:");
lines.push(promptPreview);
lines.push("");
}
// Recent log
if (task.log.length > 0) {
const recent = task.log.slice(-5);
lines.push(`Log (last ${recent.length}):`);
for (const l of recent) {
const ts = new Date(l.timestamp).toLocaleTimeString();
lines.push(
` ${ts} ${l.action}${l.outcome ? `${l.outcome}` : ""}`,
);
}
}
return {
content: [{ type: "text", text: lines.join("\n").trimEnd() }],
details: { task },
};
},
});
// ── kb_task_attach ───────────────────────────────────────────────
pi.registerTool({
name: "kb_task_attach",
label: "KB: Attach File",
description:
"Attach a file to a task. Supports images (png, jpg, gif, webp) and " +
"text files (txt, log, json, yaml, yml, toml, csv, xml).",
promptSnippet: "Attach a file to a kb task",
parameters: Type.Object({
id: Type.String({ description: "Task ID (e.g. KB-001)" }),
path: Type.String({ description: "Path to the file to attach" }),
}),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const filePath = resolve(ctx.cwd, params.path.replace(/^@/, ""));
const filename = basename(filePath);
const ext = extname(filename).toLowerCase();
const mimeType = MIME_TYPES[ext];
if (!mimeType) {
throw new Error(
`Unsupported file type: ${ext}. Supported: ${Object.keys(MIME_TYPES).join(", ")}`,
);
}
let content: Buffer;
try {
content = await readFile(filePath);
} catch {
throw new Error(`Cannot read file: ${params.path}`);
}
const store = await getStore(ctx.cwd);
const attachment = await store.addAttachment(params.id, filename, content, mimeType);
const sizeKB = (attachment.size / 1024).toFixed(1);
return {
content: [
{
type: "text",
text:
`Attached to ${params.id}: ${attachment.originalName} (${sizeKB} KB)\n` +
`Path: .kb/tasks/${params.id}/attachments/${attachment.filename}`,
},
],
details: { taskId: params.id, attachment },
};
},
});
// ── kb_task_pause ────────────────────────────────────────────────
pi.registerTool({
name: "kb_task_pause",
label: "KB: Pause Task",
description:
"Pause a task — stops all automated agent and scheduler interaction for this task.",
promptSnippet: "Pause a kb task (stops automation)",
parameters: Type.Object({
id: Type.String({ description: "Task ID (e.g. KB-001)" }),
}),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const store = await getStore(ctx.cwd);
const task = await store.pauseTask(params.id, true);
return {
content: [{ type: "text", text: `Paused ${task.id}` }],
details: { taskId: task.id },
};
},
});
// ── kb_task_unpause ──────────────────────────────────────────────
pi.registerTool({
name: "kb_task_unpause",
label: "KB: Unpause Task",
description:
"Unpause a task — resumes automated agent and scheduler interaction.",
promptSnippet: "Unpause a kb task (resumes automation)",
parameters: Type.Object({
id: Type.String({ description: "Task ID (e.g. KB-001)" }),
}),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const store = await getStore(ctx.cwd);
const task = await store.pauseTask(params.id, false);
return {
content: [{ type: "text", text: `Unpaused ${task.id}` }],
details: { taskId: task.id },
};
},
});
// ── /kb command — start the dashboard + engine ───────────────────
let dashboardProcess: ChildProcess | null = null;
let dashboardPort: number | null = null;
pi.registerCommand("kb", {
description: "Start (or stop) the kb dashboard and AI engine",
handler: async (args, ctx) => {
const trimmed = (args ?? "").trim();
// /kb stop — kill the dashboard
if (trimmed === "stop") {
if (dashboardProcess) {
dashboardProcess.kill("SIGINT");
dashboardProcess = null;
dashboardPort = null;
ctx.ui.setStatus("kb", "");
ctx.ui.notify("kb dashboard stopped", "info");
} else {
ctx.ui.notify("kb dashboard is not running", "warning");
}
return;
}
// /kb status
if (trimmed === "status") {
if (dashboardProcess && !dashboardProcess.killed) {
ctx.ui.notify(`kb dashboard running on http://localhost:${dashboardPort}`, "info");
} else {
dashboardProcess = null;
dashboardPort = null;
ctx.ui.notify("kb dashboard is not running", "info");
}
return;
}
// /kb [port] — start the dashboard
if (dashboardProcess && !dashboardProcess.killed) {
ctx.ui.notify(
`kb dashboard already running on http://localhost:${dashboardPort}. Use /kb stop first.`,
"warning",
);
return;
}
const port = trimmed ? parseInt(trimmed, 10) || 4040 : 4040;
// Find the kb binary: prefer local node_modules, then global
const child = spawn("kb", ["dashboard", "--port", String(port), "--no-open"], {
cwd: ctx.cwd,
stdio: ["ignore", "pipe", "pipe"],
detached: false,
env: { ...process.env },
});
dashboardProcess = child;
dashboardPort = port;
// Watch for early exit (e.g. kb not found)
child.on("error", (err) => {
dashboardProcess = null;
dashboardPort = null;
ctx.ui.setStatus("kb", "");
ctx.ui.notify(`Failed to start kb dashboard: ${err.message}`, "error");
});
child.on("exit", (code) => {
if (dashboardProcess === child) {
dashboardProcess = null;
dashboardPort = null;
ctx.ui.setStatus("kb", "");
if (code !== 0 && code !== null) {
ctx.ui.notify(`kb dashboard exited with code ${code}`, "warning");
}
}
});
// Wait briefly to see if it crashes immediately
await new Promise((r) => setTimeout(r, 500));
if (dashboardProcess && !dashboardProcess.killed) {
const url = `http://localhost:${port}`;
ctx.ui.notify(`kb dashboard started on ${url} (AI engine active)`, "info");
const link = `\x1b]8;;${url}\x1b\\${url}\x1b]8;;\x1b\\`;
ctx.ui.setStatus("kb", `kb ● ${link}`);
}
},
});
// ── Cleanup on session end ───────────────────────────────────────
pi.on("session_shutdown", async () => {
if (dashboardProcess) {
dashboardProcess.kill("SIGINT");
dashboardProcess = null;
dashboardPort = null;
}
storeCache.clear();
});
}

View File

@@ -8,7 +8,7 @@ const dashboardClientSrc = join(__dirname, "..", "dashboard", "dist", "client");
const dashboardClientDest = join(__dirname, "dist", "client");
export default defineConfig({
entry: ["src/bin.ts"],
entry: ["src/bin.ts", "src/extension.ts"],
format: ["esm"],
platform: "node",
target: "node22",

3
pnpm-lock.yaml generated
View File

@@ -42,6 +42,9 @@ importers:
'@kb/engine':
specifier: workspace:*
version: link:../engine
'@sinclair/typebox':
specifier: ^0.34.0
version: 0.34.48
tsup:
specifier: ^8.5.1
version: 8.5.1(postcss@8.5.8)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3)