feat(KB-041): add task refine capability across core, API, UI, and CLI
- Add refineTask() to TaskStore with validation for feedback and requirements - Add POST /tasks/:id/refine API endpoint with tests - Add refine UI to TaskDetailModal for submitting feedback - Add kb_task_refine tool to pi extension for AI-driven refinement - Add 'kb task refine' CLI command for manual task refinement - Include validation constraints: max 2000 chars feedback, max 5000 chars requirements
This commit is contained in:
@@ -81,6 +81,7 @@ describe("kb pi extension", () => {
|
||||
"kb_task_pause",
|
||||
"kb_task_unpause",
|
||||
"kb_task_duplicate",
|
||||
"kb_task_refine",
|
||||
"kb_task_import_github",
|
||||
"kb_task_import_github_issue",
|
||||
"kb_task_browse_github_issues",
|
||||
|
||||
@@ -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 } = await import("./commands/task.js");
|
||||
const { runTaskCreate, runTaskList, runTaskMove, runTaskMerge, runTaskUpdate, runTaskLog, runTaskShow, runTaskAttach, runTaskPause, runTaskUnpause, runTaskImportFromGitHub, runTaskDuplicate, runTaskArchive, runTaskUnarchive, runTaskRefine } = await import("./commands/task.js");
|
||||
|
||||
const HELP = `
|
||||
kb — AI-orchestrated task board
|
||||
@@ -56,6 +56,7 @@ Usage:
|
||||
kb task log <id> <message> Add a log entry
|
||||
kb task merge <id> Merge an in-review task and close it
|
||||
kb task duplicate <id> Duplicate a task (creates copy in triage)
|
||||
kb task refine <id> [opts] Create a refinement task from done/in-review
|
||||
kb task archive <id> Archive a done task
|
||||
kb task unarchive <id> Unarchive an archived task
|
||||
kb task attach <id> <file> Attach a file to a task
|
||||
@@ -69,6 +70,7 @@ Options:
|
||||
--dev Start dashboard only (no AI engine)
|
||||
--attach <file> Attach file(s) on task create (repeatable)
|
||||
--depends <id> Declare dependency on task create (repeatable)
|
||||
--feedback <text> Refinement feedback (non-interactive 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
|
||||
@@ -176,6 +178,17 @@ async function main() {
|
||||
await runTaskDuplicate(id);
|
||||
break;
|
||||
}
|
||||
case "refine": {
|
||||
const id = args[2];
|
||||
if (!id) { console.error("Usage: kb task refine <id> [--feedback <text>]"); process.exit(1); }
|
||||
// Parse optional --feedback flag
|
||||
const feedbackIdx = args.indexOf("--feedback");
|
||||
const feedback = feedbackIdx !== -1 && feedbackIdx + 1 < args.length
|
||||
? args[feedbackIdx + 1]
|
||||
: undefined;
|
||||
await runTaskRefine(id, feedback);
|
||||
break;
|
||||
}
|
||||
case "archive": {
|
||||
const id = args[2];
|
||||
if (!id) { console.error("Usage: kb task archive <id>"); process.exit(1); }
|
||||
|
||||
@@ -28,7 +28,7 @@ vi.mock("@kb/engine", () => ({ aiMergeTask: vi.fn() }));
|
||||
|
||||
import { createInterface } from "node:readline/promises";
|
||||
import { TaskStore } from "@kb/core";
|
||||
import { runTaskShow, runTaskCreate, runTaskDuplicate } from "./task.js";
|
||||
import { runTaskShow, runTaskCreate, runTaskDuplicate, runTaskRefine } from "./task.js";
|
||||
|
||||
function makeTask(overrides: Record<string, unknown> = {}) {
|
||||
return {
|
||||
@@ -892,3 +892,137 @@ describe("runTaskDuplicate", () => {
|
||||
await expect(runTaskDuplicate("KB-999")).rejects.toThrow("Task KB-999 not found");
|
||||
});
|
||||
});
|
||||
|
||||
// --- Refine Tests ---
|
||||
|
||||
describe("runTaskRefine", () => {
|
||||
let logSpy: ReturnType<typeof vi.spyOn>;
|
||||
let errorSpy: ReturnType<typeof vi.spyOn>;
|
||||
let mockRefineTask: ReturnType<typeof vi.fn>;
|
||||
let mockRlQuestion: ReturnType<typeof vi.fn>;
|
||||
let mockRlClose: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
||||
errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
|
||||
mockRlQuestion = vi.fn();
|
||||
mockRlClose = vi.fn();
|
||||
|
||||
(createInterface as unknown as ReturnType<typeof vi.fn>).mockReturnValue({
|
||||
question: mockRlQuestion,
|
||||
close: mockRlClose,
|
||||
});
|
||||
|
||||
mockRefineTask = vi.fn().mockResolvedValue({
|
||||
id: "KB-002",
|
||||
description: "Refinement of KB-001",
|
||||
column: "triage",
|
||||
dependencies: ["KB-001"],
|
||||
steps: [],
|
||||
currentStep: 0,
|
||||
log: [],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
||||
(TaskStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
|
||||
init: vi.fn(),
|
||||
refineTask: mockRefineTask,
|
||||
}));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("refines task with interactive feedback and prints success", async () => {
|
||||
mockRlQuestion.mockResolvedValue("Need to add more tests");
|
||||
|
||||
await runTaskRefine("KB-001");
|
||||
|
||||
expect(mockRlQuestion).toHaveBeenCalledWith("What needs to be refined? ");
|
||||
expect(mockRlClose).toHaveBeenCalled();
|
||||
expect(mockRefineTask).toHaveBeenCalledOnce();
|
||||
expect(mockRefineTask).toHaveBeenCalledWith("KB-001", "Need to add more tests");
|
||||
|
||||
const successLine = logSpy.mock.calls.find(
|
||||
(call) => typeof call[0] === "string" && call[0].includes("✓ Created refinement"),
|
||||
);
|
||||
expect(successLine).toBeDefined();
|
||||
expect(successLine![0]).toContain("KB-002");
|
||||
expect(successLine![0]).toContain("KB-001");
|
||||
|
||||
// Check that dependency is printed
|
||||
const depLine = logSpy.mock.calls.find(
|
||||
(call) => typeof call[0] === "string" && call[0].includes("Dependency:"),
|
||||
);
|
||||
expect(depLine).toBeDefined();
|
||||
expect(depLine![0]).toContain("KB-001");
|
||||
});
|
||||
|
||||
it("refines task with provided feedback (non-interactive)", async () => {
|
||||
await runTaskRefine("KB-001", "Fix the error handling");
|
||||
|
||||
expect(mockRlQuestion).not.toHaveBeenCalled();
|
||||
expect(mockRefineTask).toHaveBeenCalledOnce();
|
||||
expect(mockRefineTask).toHaveBeenCalledWith("KB-001", "Fix the error handling");
|
||||
});
|
||||
|
||||
it("exits when interactive feedback is empty", async () => {
|
||||
mockRlQuestion.mockResolvedValue(" ");
|
||||
|
||||
const exitSpy = vi.spyOn(process, "exit").mockImplementation((() => {}) as (code?: number) => never);
|
||||
|
||||
await runTaskRefine("KB-001");
|
||||
|
||||
expect(mockRlClose).toHaveBeenCalled();
|
||||
expect(errorSpy).toHaveBeenCalledWith("Feedback is required");
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
|
||||
exitSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("exits when provided feedback is empty", async () => {
|
||||
const exitSpy = vi.spyOn(process, "exit").mockImplementation((() => {}) as (code?: number) => never);
|
||||
|
||||
await runTaskRefine("KB-001", " ");
|
||||
|
||||
expect(errorSpy).toHaveBeenCalledWith("Feedback is required");
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
|
||||
exitSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("exits when feedback exceeds 2000 characters", async () => {
|
||||
const exitSpy = vi.spyOn(process, "exit").mockImplementation((() => {}) as (code?: number) => never);
|
||||
|
||||
await runTaskRefine("KB-001", "A".repeat(2001));
|
||||
|
||||
expect(errorSpy).toHaveBeenCalledWith("Feedback must be 2000 characters or less");
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
|
||||
exitSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("allows feedback at exactly 2000 characters", async () => {
|
||||
const longFeedback = "A".repeat(2000);
|
||||
|
||||
await runTaskRefine("KB-001", longFeedback);
|
||||
|
||||
expect(mockRefineTask).toHaveBeenCalledOnce();
|
||||
expect(mockRefineTask).toHaveBeenCalledWith("KB-001", longFeedback);
|
||||
});
|
||||
|
||||
it("throws when task not in done or in-review", async () => {
|
||||
mockRefineTask.mockRejectedValueOnce(new Error("Task must be in 'done' or 'in-review' column to refine"));
|
||||
|
||||
await expect(runTaskRefine("KB-001", "Some feedback")).rejects.toThrow("done' or 'in-review'");
|
||||
});
|
||||
|
||||
it("throws when task not found", async () => {
|
||||
mockRefineTask.mockRejectedValueOnce(new Error("Task KB-999 not found"));
|
||||
|
||||
await expect(runTaskRefine("KB-999", "Some feedback")).rejects.toThrow("Task KB-999 not found");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -296,6 +296,38 @@ export async function runTaskDuplicate(id: string) {
|
||||
console.log();
|
||||
}
|
||||
|
||||
export async function runTaskRefine(id: string, feedbackArg?: string) {
|
||||
const store = await getStore();
|
||||
|
||||
// Get feedback interactively only if not provided (undefined)
|
||||
let feedback = feedbackArg;
|
||||
if (feedback === undefined) {
|
||||
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
||||
feedback = await rl.question("What needs to be refined? ");
|
||||
rl.close();
|
||||
}
|
||||
|
||||
if (!feedback?.trim()) {
|
||||
console.error("Feedback is required");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// Validate length (matches API validation)
|
||||
if (feedback.length > 2000) {
|
||||
console.error("Feedback must be 2000 characters or less");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const newTask = await store.refineTask(id, feedback.trim());
|
||||
|
||||
console.log();
|
||||
console.log(` ✓ Created refinement ${newTask.id} for ${id}`);
|
||||
console.log(` Column: triage`);
|
||||
console.log(` Dependency: ${id}`);
|
||||
console.log(` Path: .kb/tasks/${newTask.id}/`);
|
||||
console.log();
|
||||
}
|
||||
|
||||
export async function runTaskArchive(id: string) {
|
||||
const store = await getStore();
|
||||
const task = await store.archiveTask(id);
|
||||
|
||||
@@ -371,6 +371,44 @@ export default function kbExtension(pi: ExtensionAPI) {
|
||||
},
|
||||
});
|
||||
|
||||
// ── kb_task_refine ──────────────────────────────────────────────
|
||||
|
||||
pi.registerTool({
|
||||
name: "kb_task_refine",
|
||||
label: "KB: Refine Task",
|
||||
description:
|
||||
"Request a refinement of a completed or in-review task. " +
|
||||
"Creates a new follow-up task in triage that references the original task as a dependency. " +
|
||||
"Use this when a done or in-review task needs additional work, improvements, or follow-up changes.",
|
||||
promptSnippet: "Create a refinement task for follow-up work on a completed task",
|
||||
promptGuidelines: [
|
||||
"Use when a completed or in-review task needs follow-up work or improvements",
|
||||
"The original task must be in 'done' or 'in-review' column",
|
||||
"The refinement task will be created in triage and depend on the original task",
|
||||
"Provide clear feedback about what needs to be refined or improved",
|
||||
],
|
||||
parameters: Type.Object({
|
||||
id: Type.String({ description: "Task ID to refine (e.g. KB-001). Must be in 'done' or 'in-review' column." }),
|
||||
feedback: Type.String({
|
||||
description: "Description of what needs to be refined or improved",
|
||||
minLength: 1,
|
||||
maxLength: 2000,
|
||||
}),
|
||||
}),
|
||||
|
||||
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
||||
const store = await getStore(ctx.cwd);
|
||||
const newTask = await store.refineTask(params.id, params.feedback);
|
||||
|
||||
return {
|
||||
content: [
|
||||
{ type: "text", text: `Created refinement ${newTask.id} for ${params.id}` },
|
||||
],
|
||||
details: { sourceId: params.id, newTaskId: newTask.id, feedback: params.feedback },
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
// ── kb_task_archive ───────────────────────────────────────────────
|
||||
|
||||
pi.registerTool({
|
||||
|
||||
Reference in New Issue
Block a user