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:
gsxdsm
2026-03-29 21:05:57 -07:00
parent 61303f1438
commit 1219d63b95
13 changed files with 1149 additions and 4 deletions

View File

@@ -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");
});
});

View File

@@ -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);