feat: pi extension
This commit is contained in:
@@ -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.
|
||||
@@ -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
|
||||
```
|
||||
5
.changeset/add-pi-extension.md
Normal file
5
.changeset/add-pi-extension.md
Normal 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.
|
||||
5
.changeset/related-task-deps.md
Normal file
5
.changeset/related-task-deps.md
Normal 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
33
.pi/agents/supervisor.md
Normal 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
9
.pi/taskplane.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"migrations": {
|
||||
"applied": {
|
||||
"add-supervisor-local-template-v1": {
|
||||
"appliedAt": "2026-03-29T00:55:05.137Z"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
23
AGENTS.md
23
AGENTS.md
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
384
packages/cli/src/__tests__/extension.test.ts
Normal file
384
packages/cli/src/__tests__/extension.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
});
|
||||
443
packages/cli/src/extension.ts
Normal file
443
packages/cli/src/extension.ts
Normal 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();
|
||||
});
|
||||
}
|
||||
@@ -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
3
pnpm-lock.yaml
generated
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user