feat(KB-181): add kb task plan command with planning mode

- Add kb task plan CLI command for interactive planning mode
- Create planTask tool in extension for LLM-initiated planning sessions
- Add comprehensive tests for task plan command with edge cases
- Update QuickEntryBox UI with planning mode button
- Add changeset for planning mode CLI feature
This commit is contained in:
gsxdsm
2026-03-30 11:53:16 -07:00
parent b372200949
commit 6631148ea9
7 changed files with 1342 additions and 1 deletions

View File

@@ -87,6 +87,7 @@ describe("kb pi extension", () => {
"kb_task_browse_github_issues",
"kb_task_archive",
"kb_task_unarchive",
"kb_task_plan",
];
for (const name of expected) {

View File

@@ -0,0 +1,859 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
// Mock node:readline/promises before importing
vi.mock("node:readline/promises", () => ({
createInterface: vi.fn(),
}));
// Mock @kb/core before importing
vi.mock("@kb/core", () => ({
TaskStore: vi.fn(),
COLUMNS: ["triage", "todo", "in-progress", "in-review", "done", "archived"],
COLUMN_LABELS: {
triage: "Triage",
todo: "Todo",
"in-progress": "In Progress",
"in-review": "In Review",
done: "Done",
archived: "Archived",
},
}));
// Mock @kb/dashboard/planning
vi.mock("@kb/dashboard/planning", () => ({
createSession: vi.fn(),
submitResponse: vi.fn(),
RateLimitError: class RateLimitError extends Error {
constructor(message: string) {
super(message);
this.name = "RateLimitError";
}
},
SessionNotFoundError: class SessionNotFoundError extends Error {
constructor(message: string) {
super(message);
this.name = "SessionNotFoundError";
}
},
InvalidSessionStateError: class InvalidSessionStateError extends Error {
constructor(message: string) {
super(message);
this.name = "InvalidSessionStateError";
}
},
}));
// Import after mocking
import { createInterface } from "node:readline/promises";
import { TaskStore } from "@kb/core";
import { createSession, submitResponse, RateLimitError, SessionNotFoundError } from "@kb/dashboard/planning";
import { runTaskPlan } from "../commands/task.js";
describe("runTaskPlan", () => {
let mockConsoleLog: ReturnType<typeof vi.spyOn>;
let mockConsoleError: ReturnType<typeof vi.spyOn>;
let mockStdoutWrite: ReturnType<typeof vi.spyOn>;
const mockQuestion = vi.fn();
const mockClose = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
mockConsoleLog = vi.spyOn(console, "log").mockImplementation(() => {});
mockConsoleError = vi.spyOn(console, "error").mockImplementation(() => {});
mockStdoutWrite = vi.spyOn(process.stdout, "write").mockImplementation(() => true);
mockQuestion.mockReset();
(createInterface as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
question: mockQuestion,
close: mockClose,
});
});
afterEach(() => {
mockConsoleLog.mockRestore();
mockConsoleError.mockRestore();
mockStdoutWrite.mockRestore();
});
function setupTaskStoreMock(overrides: Record<string, unknown> = {}) {
(TaskStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
init: vi.fn().mockResolvedValue(undefined),
createTask: vi.fn().mockResolvedValue({
id: "KB-042",
title: "Test Task Title",
description: "Test description",
column: "triage",
dependencies: ["KB-001"],
...overrides,
}),
}));
}
it("prompts for initial plan when not provided", async () => {
setupTaskStoreMock();
(createSession as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
sessionId: "test-session-123",
firstQuestion: {
id: "q1",
type: "confirm",
question: "Is this a test?",
description: "Test description",
},
});
(submitResponse as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
type: "complete",
data: {
title: "Test Task",
description: "A test task",
suggestedSize: "S",
suggestedDependencies: [],
keyDeliverables: ["Test delivery"],
},
});
mockQuestion
.mockResolvedValueOnce("Build a test feature")
.mockResolvedValueOnce("y");
const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => {
throw new Error("Process.exit called");
});
try {
await runTaskPlan(undefined, true);
} catch {
// expected
}
expect(createSession).toHaveBeenCalledWith(
"127.0.0.1",
"Build a test feature",
expect.any(Object),
expect.any(String)
);
exitSpy.mockRestore();
});
it("handles text question flow (multi-line input)", async () => {
setupTaskStoreMock();
(createSession as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
sessionId: "test-session-123",
firstQuestion: {
id: "q-text",
type: "text",
question: "What are the requirements?",
description: "Describe your requirements",
},
});
(submitResponse as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
type: "complete",
data: {
title: "Test Task",
description: "Requirements: Test requirements",
suggestedSize: "M",
suggestedDependencies: [],
keyDeliverables: ["Implementation"],
},
});
mockQuestion
.mockResolvedValueOnce("Line 1")
.mockResolvedValueOnce("Line 2")
.mockResolvedValueOnce("DONE");
const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => {
throw new Error("Process.exit called");
});
try {
await runTaskPlan("Build something", true);
} catch {
// expected
}
expect(submitResponse).toHaveBeenCalledWith(
"test-session-123",
{ "q-text": "Line 1\nLine 2" }
);
exitSpy.mockRestore();
});
it("handles single_select question flow", async () => {
setupTaskStoreMock();
(createSession as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
sessionId: "test-session-123",
firstQuestion: {
id: "q-scope",
type: "single_select",
question: "What is the scope?",
description: "Select scope",
options: [
{ id: "small", label: "Small", description: "Quick fix" },
{ id: "large", label: "Large", description: "Big feature" },
],
},
});
(submitResponse as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
type: "complete",
data: {
title: "Test Task",
description: "Scope: small",
suggestedSize: "S",
suggestedDependencies: [],
keyDeliverables: ["Implementation"],
},
});
mockQuestion.mockResolvedValueOnce("1");
const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => {
throw new Error("Process.exit called");
});
try {
await runTaskPlan("Build something", true);
} catch {
// expected
}
expect(submitResponse).toHaveBeenCalledWith(
"test-session-123",
{ "q-scope": "small" }
);
exitSpy.mockRestore();
});
it("handles multi_select question flow", async () => {
setupTaskStoreMock();
(createSession as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
sessionId: "test-session-123",
firstQuestion: {
id: "q-features",
type: "multi_select",
question: "Select features",
description: "Choose features to include",
options: [
{ id: "feat1", label: "Feature 1", description: "First feature" },
{ id: "feat2", label: "Feature 2", description: "Second feature" },
{ id: "feat3", label: "Feature 3", description: "Third feature" },
],
},
});
(submitResponse as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
type: "complete",
data: {
title: "Test Task",
description: "Features: feat1, feat3",
suggestedSize: "M",
suggestedDependencies: [],
keyDeliverables: ["Feature 1", "Feature 3"],
},
});
mockQuestion.mockResolvedValueOnce("1,3");
const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => {
throw new Error("Process.exit called");
});
try {
await runTaskPlan("Build something", true);
} catch {
// expected
}
expect(submitResponse).toHaveBeenCalledWith(
"test-session-123",
{ "q-features": ["feat1", "feat3"] }
);
exitSpy.mockRestore();
});
it("handles confirm question flow with yes", async () => {
setupTaskStoreMock();
(createSession as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
sessionId: "test-session-123",
firstQuestion: {
id: "q-confirm",
type: "confirm",
question: "Do you need authentication?",
description: "Security requirement",
},
});
(submitResponse as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
type: "complete",
data: {
title: "Test Task",
description: "Auth required: yes",
suggestedSize: "M",
suggestedDependencies: [],
keyDeliverables: ["Auth system"],
},
});
mockQuestion.mockResolvedValueOnce("y");
const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => {
throw new Error("Process.exit called");
});
try {
await runTaskPlan("Build something", true);
} catch {
// expected
}
expect(submitResponse).toHaveBeenCalledWith(
"test-session-123",
{ "q-confirm": true }
);
exitSpy.mockRestore();
});
it("handles confirm question flow with no", async () => {
setupTaskStoreMock();
(createSession as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
sessionId: "test-session-123",
firstQuestion: {
id: "q-confirm",
type: "confirm",
question: "Do you need authentication?",
description: "Security requirement",
},
});
(submitResponse as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
type: "complete",
data: {
title: "Test Task",
description: "Auth required: no",
suggestedSize: "S",
suggestedDependencies: [],
keyDeliverables: ["Basic implementation"],
},
});
mockQuestion.mockResolvedValueOnce("n");
const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => {
throw new Error("Process.exit called");
});
try {
await runTaskPlan("Build something", true);
} catch {
// expected
}
expect(submitResponse).toHaveBeenCalledWith(
"test-session-123",
{ "q-confirm": false }
);
exitSpy.mockRestore();
});
it("creates task after planning completes with --yes flag", async () => {
const mockCreateTask = vi.fn().mockResolvedValue({
id: "KB-042",
title: "Planned Task",
description: "A well-planned task",
column: "triage",
dependencies: ["KB-001"],
});
(TaskStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
init: vi.fn().mockResolvedValue(undefined),
createTask: mockCreateTask,
}));
(createSession as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
sessionId: "test-session-123",
firstQuestion: {
id: "q1",
type: "confirm",
question: "Ready?",
description: "Confirm to complete",
},
});
(submitResponse as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
type: "complete",
data: {
title: "Planned Task",
description: "A well-planned task",
suggestedSize: "M",
suggestedDependencies: ["KB-001"],
keyDeliverables: ["Code", "Tests"],
},
});
mockQuestion.mockResolvedValueOnce("y");
const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => {
throw new Error("Process.exit called");
});
try {
await runTaskPlan("Build something", true);
} catch {
// expected
}
expect(mockCreateTask).toHaveBeenCalledWith({
title: "Planned Task",
description: "A well-planned task",
column: "triage",
dependencies: ["KB-001"],
});
exitSpy.mockRestore();
});
it("prompts for confirmation without --yes flag", async () => {
setupTaskStoreMock();
(createSession as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
sessionId: "test-session-123",
firstQuestion: {
id: "q1",
type: "confirm",
question: "Ready?",
description: "Confirm to complete",
},
});
(submitResponse as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
type: "complete",
data: {
title: "Planned Task",
description: "A well-planned task",
suggestedSize: "M",
suggestedDependencies: [],
keyDeliverables: ["Code"],
},
});
mockQuestion
.mockResolvedValueOnce("y")
.mockResolvedValueOnce("y");
const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => {
throw new Error("Process.exit called");
});
try {
await runTaskPlan("Build something", false);
} catch {
// expected
}
expect(mockQuestion).toHaveBeenLastCalledWith(" Create this task? [Y/n]: ");
exitSpy.mockRestore();
});
it("handles RateLimitError with proper message", async () => {
setupTaskStoreMock();
(createSession as unknown as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
new RateLimitError("Rate limit exceeded")
);
const exitSpy = vi.spyOn(process, "exit").mockImplementation((code) => {
throw new Error(`Process.exit called with ${code}`);
});
await expect(runTaskPlan("Build something", true)).rejects.toThrow();
expect(mockConsoleError).toHaveBeenCalledWith(
expect.stringContaining("Rate limit exceeded")
);
exitSpy.mockRestore();
});
it("handles SessionNotFoundError with proper message", async () => {
setupTaskStoreMock();
(createSession as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
sessionId: "test-session-123",
firstQuestion: {
id: "q1",
type: "text",
question: "Question?",
description: "Answer me",
},
});
(submitResponse as unknown as ReturnType<typeof vi.fn>).mockRejectedValueOnce(
new SessionNotFoundError("Session not found")
);
mockQuestion
.mockResolvedValueOnce("answer")
.mockResolvedValueOnce("DONE");
const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => {
throw new Error("Process.exit called");
});
try {
await runTaskPlan("Build something", true);
} catch {
// expected
}
expect(mockConsoleError).toHaveBeenCalledWith(
expect.stringContaining("Session expired")
);
exitSpy.mockRestore();
});
it("cancels planning when user enters empty initial plan", async () => {
mockQuestion.mockResolvedValueOnce("");
const exitSpy = vi.spyOn(process, "exit").mockImplementation((code) => {
throw new Error(`Process.exit called with ${code}`);
});
await expect(runTaskPlan(undefined, false)).rejects.toThrow();
expect(mockConsoleError).toHaveBeenCalledWith(
expect.stringContaining("Description is required")
);
exitSpy.mockRestore();
});
it("skips task creation when user declines confirmation", async () => {
const mockCreateTask = vi.fn().mockResolvedValue({ id: "KB-042" });
(TaskStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
init: vi.fn().mockResolvedValue(undefined),
createTask: mockCreateTask,
}));
(createSession as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
sessionId: "test-session-123",
firstQuestion: {
id: "q1",
type: "confirm",
question: "Ready?",
description: "Confirm to complete",
},
});
(submitResponse as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
type: "complete",
data: {
title: "Planned Task",
description: "A well-planned task",
suggestedSize: "M",
suggestedDependencies: [],
keyDeliverables: ["Code"],
},
});
mockQuestion
.mockResolvedValueOnce("y")
.mockResolvedValueOnce("n");
const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => {
throw new Error("Process.exit called");
});
try {
await runTaskPlan("Build something", false);
} catch {
// expected
}
expect(mockCreateTask).not.toHaveBeenCalled();
expect(mockConsoleLog).toHaveBeenCalledWith(
expect.stringContaining("Task creation cancelled")
);
exitSpy.mockRestore();
});
it("validates single_select input and retries on invalid", async () => {
setupTaskStoreMock();
(createSession as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
sessionId: "test-session-123",
firstQuestion: {
id: "q-scope",
type: "single_select",
question: "Select scope",
description: "Choose scope",
options: [
{ id: "small", label: "Small", description: "Quick" },
{ id: "large", label: "Large", description: "Big" },
],
},
});
(submitResponse as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
type: "complete",
data: {
title: "Test",
description: "Test",
suggestedSize: "S",
suggestedDependencies: [],
keyDeliverables: ["Test"],
},
});
mockQuestion
.mockResolvedValueOnce("abc")
.mockResolvedValueOnce("5")
.mockResolvedValueOnce("1");
const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => {
throw new Error("Process.exit called");
});
try {
await runTaskPlan("Build something", true);
} catch {
// expected
}
expect(mockQuestion).toHaveBeenCalledTimes(3);
expect(submitResponse).toHaveBeenCalledWith(
"test-session-123",
{ "q-scope": "small" }
);
exitSpy.mockRestore();
});
it("validates multi_select input and retries on invalid", async () => {
setupTaskStoreMock();
(createSession as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
sessionId: "test-session-123",
firstQuestion: {
id: "q-features",
type: "multi_select",
question: "Select features",
description: "Choose features",
options: [
{ id: "f1", label: "Feature 1", description: "First" },
{ id: "f2", label: "Feature 2", description: "Second" },
],
},
});
(submitResponse as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
type: "complete",
data: {
title: "Test",
description: "Test",
suggestedSize: "S",
suggestedDependencies: [],
keyDeliverables: ["Test"],
},
});
mockQuestion
.mockResolvedValueOnce("")
.mockResolvedValueOnce("1,5")
.mockResolvedValueOnce("1,2");
const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => {
throw new Error("Process.exit called");
});
try {
await runTaskPlan("Build something", true);
} catch {
// expected
}
expect(mockQuestion).toHaveBeenCalledTimes(3);
expect(submitResponse).toHaveBeenCalledWith(
"test-session-123",
{ "q-features": ["f1", "f2"] }
);
exitSpy.mockRestore();
});
it("continues to next question after answering", async () => {
setupTaskStoreMock();
(createSession as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
sessionId: "test-session-123",
firstQuestion: {
id: "q1",
type: "confirm",
question: "First question?",
description: "Answer this",
},
});
(submitResponse as unknown as ReturnType<typeof vi.fn>)
.mockResolvedValueOnce({
type: "question",
data: {
id: "q2",
type: "text",
question: "Second question?",
description: "More details",
},
})
.mockResolvedValueOnce({
type: "complete",
data: {
title: "Test",
description: "Test with multiple questions",
suggestedSize: "M",
suggestedDependencies: [],
keyDeliverables: ["Answer 1", "Answer 2"],
},
});
mockQuestion
.mockResolvedValueOnce("y")
.mockResolvedValueOnce("Answer text")
.mockResolvedValueOnce("DONE");
const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => {
throw new Error("Process.exit called");
});
try {
await runTaskPlan("Build something", true);
} catch {
// expected
}
expect(submitResponse).toHaveBeenCalledTimes(2);
expect(submitResponse).toHaveBeenNthCalledWith(
1,
"test-session-123",
{ q1: true }
);
expect(submitResponse).toHaveBeenNthCalledWith(
2,
"test-session-123",
{ q2: "Answer text" }
);
exitSpy.mockRestore();
});
it("uses default yes for confirm when user presses Enter", async () => {
setupTaskStoreMock();
(createSession as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
sessionId: "test-session-123",
firstQuestion: {
id: "q-confirm",
type: "confirm",
question: "Continue?",
description: "Press Enter for yes",
},
});
(submitResponse as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
type: "complete",
data: {
title: "Test",
description: "Test",
suggestedSize: "S",
suggestedDependencies: [],
keyDeliverables: ["Test"],
},
});
mockQuestion.mockResolvedValueOnce("");
const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => {
throw new Error("Process.exit called");
});
try {
await runTaskPlan("Build something", true);
} catch {
// expected
}
expect(submitResponse).toHaveBeenCalledWith(
"test-session-123",
{ "q-confirm": true }
);
exitSpy.mockRestore();
});
it("displays summary with all fields correctly formatted", async () => {
setupTaskStoreMock();
(createSession as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
sessionId: "test-session-123",
firstQuestion: {
id: "q1",
type: "confirm",
question: "Ready?",
description: "Confirm",
},
});
(submitResponse as unknown as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
type: "complete",
data: {
title: "Complete Auth System",
description: "Build a comprehensive authentication system with login, logout, and password reset functionality. Includes email verification and 2FA support.",
suggestedSize: "L",
suggestedDependencies: ["KB-001", "KB-002"],
keyDeliverables: [
"User login with email/password",
"Password reset via email",
"Two-factor authentication",
"Session management",
"API integration tests",
],
},
});
mockQuestion.mockResolvedValueOnce("y");
const exitSpy = vi.spyOn(process, "exit").mockImplementation(() => {
throw new Error("Process.exit called");
});
try {
await runTaskPlan("Build auth system", true);
} catch {
// expected
}
expect(mockConsoleLog).toHaveBeenCalledWith(
expect.stringContaining("Planning Summary")
);
expect(mockConsoleLog).toHaveBeenCalledWith(
expect.stringContaining("Complete Auth System")
);
exitSpy.mockRestore();
});
});

View File

@@ -39,7 +39,7 @@ if (isBunBinary) {
// Dynamic imports so the pi-coding-agent config module sees PI_PACKAGE_DIR
const { runDashboard } = await import("./commands/dashboard.js");
const { runTaskCreate, runTaskList, runTaskMove, runTaskMerge, runTaskUpdate, runTaskLog, runTaskShow, runTaskAttach, runTaskPause, runTaskUnpause, runTaskImportFromGitHub, runTaskDuplicate, runTaskArchive, runTaskUnarchive, runTaskRefine } = await import("./commands/task.js");
const { runTaskCreate, runTaskList, runTaskMove, runTaskMerge, runTaskUpdate, runTaskLog, runTaskShow, runTaskAttach, runTaskPause, runTaskUnpause, runTaskImportFromGitHub, runTaskDuplicate, runTaskArchive, runTaskUnarchive, runTaskRefine, runTaskPlan } = await import("./commands/task.js");
const HELP = `
kb — AI-orchestrated task board
@@ -50,6 +50,7 @@ Usage:
kb dashboard --dev Start web UI only (no AI engine)
kb dashboard --interactive Start with interactive port selection
kb task create [desc] [opts] Create a new task (goes to triage)
kb task plan [description] [opts] Create task via AI-guided planning
kb task list List all tasks
kb task show <id> Show task details, steps, log
kb task move <id> <col> Move a task to a column
@@ -73,6 +74,7 @@ Options:
--attach <file> Attach file(s) on task create (repeatable)
--depends <id> Declare dependency on task create (repeatable)
--feedback <text> Refinement feedback (non-interactive mode)
--yes Skip confirmation prompts (planning mode)
--limit, -l <n> Max issues to import (default: 30, max: 100)
--labels, -L <labels> Comma-separated label filter for import
--interactive, -i Interactive mode for issue selection
@@ -132,6 +134,21 @@ async function main() {
await runTaskCreate(title || undefined, attachFiles.length > 0 ? attachFiles : undefined, dependsIds.length > 0 ? dependsIds : undefined);
break;
}
case "plan": {
const planArgs = args.slice(2);
const yesFlag = planArgs.includes("--yes");
const descParts: string[] = [];
for (let i = 0; i < planArgs.length; i++) {
if (planArgs[i] === "--yes") {
continue; // skip flag
} else {
descParts.push(planArgs[i]);
}
}
const initialPlan = descParts.join(" ");
await runTaskPlan(initialPlan || undefined, yesFlag);
break;
}
case "list":
case "ls":
await runTaskList();

View File

@@ -1,6 +1,8 @@
import { TaskStore, COLUMNS, COLUMN_LABELS, type Column, type MergeResult, type StepStatus } from "@kb/core";
import { aiMergeTask } from "@kb/engine";
import { createInterface } from "node:readline/promises";
import type { PlanningQuestion, PlanningSummary } from "@kb/core";
import { createSession, submitResponse, RateLimitError, SessionNotFoundError, InvalidSessionStateError } from "@kb/dashboard/planning";
const STEP_STATUSES: StepStatus[] = ["pending", "in-progress", "done", "skipped"];
@@ -642,3 +644,372 @@ export async function runTaskImportFromGitHub(
console.log(` ✓ Imported ${created} tasks from ${owner}/${repo}${skipped > 0 ? ` (${skipped} skipped)` : ""}`);
console.log();
}
// ── Planning Mode ───────────────────────────────────────────────────────────
/** Helper to display thinking indicator */
function showThinking(): void {
process.stdout.write(" AI is thinking...");
}
/** Helper to clear thinking indicator */
function clearThinking(): void {
process.stdout.write("\r" + " ".repeat(20) + "\r");
}
/** Prompt for text (multi-line) question */
async function promptText(question: PlanningQuestion): Promise<string> {
console.log(`\n ${question.question}`);
if (question.description) {
console.log(` ${question.description}`);
}
console.log(" (Enter your response. Type DONE on its own line when finished):\n");
const rl = createInterface({ input: process.stdin, output: process.stdout });
const lines: string[] = [];
return new Promise((resolve) => {
const askLine = () => {
rl.question(" ").then((line) => {
if (line.trim() === "DONE") {
rl.close();
resolve(lines.join("\n"));
} else {
lines.push(line);
askLine();
}
});
};
askLine();
});
}
/** Prompt for single_select question */
async function promptSingleSelect(question: PlanningQuestion): Promise<string> {
console.log(`\n ${question.question}`);
if (question.description) {
console.log(` ${question.description}`);
}
console.log();
if (!question.options || question.options.length === 0) {
throw new Error("Single select question has no options");
}
for (let i = 0; i < question.options.length; i++) {
const opt = question.options[i];
console.log(` ${i + 1}. ${opt.label}`);
if (opt.description) {
console.log(` ${opt.description}`);
}
}
const rl = createInterface({ input: process.stdin, output: process.stdout });
while (true) {
const answer = await rl.question("\n Select (1-" + question.options.length + "): ");
const num = parseInt(answer.trim(), 10);
if (!isNaN(num) && num >= 1 && num <= question.options.length) {
rl.close();
return question.options[num - 1].id;
}
console.log(` Invalid selection. Please enter a number between 1 and ${question.options.length}`);
}
}
/** Prompt for multi_select question */
async function promptMultiSelect(question: PlanningQuestion): Promise<string[]> {
console.log(`\n ${question.question}`);
if (question.description) {
console.log(` ${question.description}`);
}
console.log(" (Enter comma-separated numbers, e.g., 1,3,4):\n");
if (!question.options || question.options.length === 0) {
throw new Error("Multi select question has no options");
}
for (let i = 0; i < question.options.length; i++) {
const opt = question.options[i];
console.log(` ${i + 1}. ${opt.label}`);
if (opt.description) {
console.log(` ${opt.description}`);
}
}
const rl = createInterface({ input: process.stdin, output: process.stdout });
while (true) {
const answer = await rl.question("\n Select (comma-separated): ");
const nums = answer
.split(",")
.map((s) => parseInt(s.trim(), 10))
.filter((n) => !isNaN(n));
if (nums.length === 0) {
console.log(" Please select at least one option");
continue;
}
const invalid = nums.filter((n) => n < 1 || n > question.options!.length);
if (invalid.length > 0) {
console.log(` Invalid selection: ${invalid.join(", ")}. Range: 1-${question.options.length}`);
continue;
}
rl.close();
return nums.map((n) => question.options![n - 1].id);
}
}
/** Prompt for confirm question */
async function promptConfirm(question: PlanningQuestion): Promise<boolean> {
console.log(`\n ${question.question}`);
if (question.description) {
console.log(` ${question.description}`);
}
const rl = createInterface({ input: process.stdin, output: process.stdout });
const answer = await rl.question("\n [Y/n]: ");
rl.close();
const trimmed = answer.trim().toLowerCase();
return trimmed === "" || trimmed === "y" || trimmed === "yes";
}
/** Display planning summary */
function displaySummary(summary: PlanningSummary): void {
console.log();
console.log(" ╔══════════════════════════════════════════════════════════════╗");
console.log(" ║ Planning Summary ║");
console.log(" ╠══════════════════════════════════════════════════════════════╣");
console.log(` ║ Title: ${summary.title.slice(0, 55).padEnd(55)}`);
console.log(" ╠══════════════════════════════════════════════════════════════╣");
// Description (wrapped to box width)
const descLines = wrapText(summary.description, 58);
for (const line of descLines.slice(0, 10)) {
console.log(`${line.padEnd(58)}`);
}
if (descLines.length > 10) {
console.log(` ║ ... (${descLines.length - 10} more lines) ...`.padEnd(62) + " ║");
}
console.log(" ╠══════════════════════════════════════════════════════════════╣");
console.log(` ║ Size: ${summary.suggestedSize.padEnd(52)}`);
if (summary.suggestedDependencies.length > 0) {
console.log(` ║ Dependencies: ${summary.suggestedDependencies.join(", ").slice(0, 45).padEnd(45)}`);
} else {
console.log(` ║ Dependencies: none`.padEnd(62) + " ║");
}
console.log(" ╠══════════════════════════════════════════════════════════════╣");
console.log(" ║ Key Deliverables: ║");
for (const deliverable of summary.keyDeliverables) {
console.log(` ║ • ${deliverable.slice(0, 54).padEnd(54)}`);
}
console.log(" ╚══════════════════════════════════════════════════════════════╝");
console.log();
}
/** Wrap text to specified width */
function wrapText(text: string, width: number): string[] {
const lines: string[] = [];
const paragraphs = text.split("\n");
for (const paragraph of paragraphs) {
if (paragraph.trim() === "") {
lines.push("");
continue;
}
let remaining = paragraph.trim();
while (remaining.length > 0) {
if (remaining.length <= width) {
lines.push(remaining);
break;
}
let breakPoint = width;
while (breakPoint > 0 && remaining[breakPoint] !== " ") {
breakPoint--;
}
if (breakPoint === 0) {
// No space found, force break
breakPoint = width;
}
lines.push(remaining.slice(0, breakPoint));
remaining = remaining.slice(breakPoint).trim();
}
}
return lines;
}
/** Run the planning mode */
export async function runTaskPlan(initialPlanArg?: string, yesFlag = false): Promise<void> {
let initialPlan = initialPlanArg;
// If no initial plan, prompt interactively
if (!initialPlan) {
const rl = createInterface({ input: process.stdin, output: process.stdout });
console.log("\n Let's plan your task. What would you like to accomplish?\n");
initialPlan = await rl.question(" Describe your idea: ");
rl.close();
if (!initialPlan?.trim()) {
console.error("\n Description is required");
process.exit(1);
}
}
const store = await getStore();
// Create planning session
let sessionId: string;
let firstQuestion: PlanningQuestion;
try {
showThinking();
const result = await createSession("127.0.0.1", initialPlan.trim(), store, process.cwd());
clearThinking();
sessionId = result.sessionId;
firstQuestion = result.firstQuestion;
} catch (err) {
clearThinking();
if (err instanceof RateLimitError) {
console.error("\n Rate limit exceeded. Maximum 5 planning sessions per hour.\n");
process.exit(1);
}
console.error(`\n Failed to start planning session: ${err instanceof Error ? err.message : String(err)}\n`);
process.exit(1);
}
// Interactive Q&A loop
let currentQuestion = firstQuestion;
let cancelled = false;
// Handle Ctrl+C gracefully
const handleSigint = () => {
cancelled = true;
console.log("\n\n Planning session cancelled.\n");
process.exit(0);
};
process.on("SIGINT", handleSigint);
try {
while (!cancelled) {
// Get user response based on question type
let response: unknown;
try {
switch (currentQuestion.type) {
case "text": {
const textResponse = await promptText(currentQuestion);
response = { [currentQuestion.id]: textResponse };
break;
}
case "single_select": {
const selectResponse = await promptSingleSelect(currentQuestion);
response = { [currentQuestion.id]: selectResponse };
break;
}
case "multi_select": {
const multiResponse = await promptMultiSelect(currentQuestion);
response = { [currentQuestion.id]: multiResponse };
break;
}
case "confirm": {
const confirmResponse = await promptConfirm(currentQuestion);
response = { [currentQuestion.id]: confirmResponse };
break;
}
default: {
console.error(`\n Unknown question type: ${(currentQuestion as any).type}`);
process.exit(1);
}
}
} catch (promptErr) {
// Prompt was cancelled (Ctrl+C handled above)
if (cancelled) {
return;
}
throw promptErr;
}
// Submit response and get next question or summary
let result: { type: "question"; data: PlanningQuestion } | { type: "complete"; data: PlanningSummary };
try {
showThinking();
result = await submitResponse(sessionId, response) as typeof result;
clearThinking();
} catch (err) {
clearThinking();
if (err instanceof SessionNotFoundError) {
console.error("\n Session expired. Please start again.\n");
process.exit(1);
}
if (err instanceof InvalidSessionStateError) {
console.error(`\n Invalid session state: ${err.message}\n`);
process.exit(1);
}
console.error(`\n Error: ${err instanceof Error ? err.message : String(err)}\n`);
process.exit(1);
}
if (result.type === "complete") {
// Display summary
displaySummary(result.data);
// Ask for confirmation (unless --yes flag)
let confirmed = yesFlag;
if (!yesFlag) {
const rl = createInterface({ input: process.stdin, output: process.stdout });
const answer = await rl.question(" Create this task? [Y/n]: ");
rl.close();
const trimmed = answer.trim().toLowerCase();
confirmed = trimmed === "" || trimmed === "y" || trimmed === "yes";
}
if (confirmed) {
// Create the task
const task = await store.createTask({
title: result.data.title,
description: result.data.description,
column: "triage",
dependencies: result.data.suggestedDependencies,
});
console.log();
console.log(` ✓ Created ${task.id}: ${task.title || task.description.slice(0, 60)}${task.description.length > 60 ? "…" : ""}`);
console.log(` Column: triage`);
if (task.dependencies.length > 0) {
console.log(` Dependencies: ${task.dependencies.join(", ")}`);
}
console.log(` Path: .kb/tasks/${task.id}/`);
console.log();
} else {
console.log("\n Task creation cancelled.\n");
}
break;
}
// Next question
currentQuestion = result.data;
}
} finally {
process.off("SIGINT", handleSigint);
}
}

View File

@@ -818,6 +818,77 @@ export default function kbExtension(pi: ExtensionAPI) {
},
});
// ── kb_task_plan ────────────────────────────────────────────────
// Create a task via AI-guided planning mode
pi.registerTool({
name: "kb_task_plan",
label: "KB: Plan Task",
description:
"Create a task via AI-guided planning mode — interactive conversation to refine your idea into a well-specified task.",
promptSnippet: "Create a task via AI-guided planning mode",
promptGuidelines: [
"Use for breaking down vague ideas into actionable tasks",
"The AI will ask clarifying questions before creating the task",
],
parameters: Type.Object({
description: Type.Optional(
Type.String({
description: "Initial plan description (optional) — the AI will ask clarifying questions if not provided",
})
),
}),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
// Import the planning function dynamically to avoid circular dependencies
const { runTaskPlan } = await import("./commands/task.js");
// Capture console output
const originalLog = console.log;
const originalError = console.error;
const logs: string[] = [];
console.log = (...args: unknown[]) => {
const line = args.map(String).join(" ");
logs.push(line);
originalLog.apply(console, args);
};
console.error = (...args: unknown[]) => {
const line = args.map(String).join(" ");
logs.push(line);
originalError.apply(console, args);
};
try {
await runTaskPlan(params.description, true); // Use --yes flag for non-interactive
} catch (err: any) {
console.error = originalError;
console.log = originalLog;
throw new Error(`Planning mode failed: ${err.message}`);
} finally {
console.error = originalError;
console.log = originalLog;
}
// Parse created task ID from logs
const createdMatch = logs.find((l) => l.match(/Created (KB-\d+):/));
const taskId = createdMatch ? createdMatch.match(/Created (KB-\d+):/)?.[1] : undefined;
// Get summary line
const summaryLine = logs.find((l) => l.includes("✓ Created")) || "Task created";
return {
content: [
{
type: "text",
text: summaryLine + (taskId ? `\n\nPlanning session completed. Task ${taskId} is now in triage and will be auto-specified by the AI triage agent.` : ""),
},
],
details: { taskId, logs },
};
},
});
// ── /kb command — start the dashboard + engine ───────────────────
let dashboardProcess: ChildProcess | null = null;

View File

@@ -6,6 +6,10 @@
".": {
"types": "./src/index.ts",
"import": "./dist/index.js"
},
"./planning": {
"types": "./src/planning.ts",
"import": "./src/planning.ts"
}
},
"publishConfig": {