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:
@@ -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) {
|
||||
|
||||
859
packages/cli/src/__tests__/task-plan.test.ts
Normal file
859
packages/cli/src/__tests__/task-plan.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -6,6 +6,10 @@
|
||||
".": {
|
||||
"types": "./src/index.ts",
|
||||
"import": "./dist/index.js"
|
||||
},
|
||||
"./planning": {
|
||||
"types": "./src/planning.ts",
|
||||
"import": "./src/planning.ts"
|
||||
}
|
||||
},
|
||||
"publishConfig": {
|
||||
|
||||
Reference in New Issue
Block a user