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

@@ -1564,6 +1564,241 @@ describe("TaskStore", () => {
});
});
// ── Refine Task Tests ────────────────────────────────────────────
describe("refineTask", () => {
it("creates refinement from done task", async () => {
const task = await store.createTask({ description: "Original task" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.moveTask(task.id, "done");
const refined = await store.refineTask(task.id, "Need to fix edge case");
expect(refined.id).not.toBe(task.id);
expect(refined.id).toMatch(/^KB-\d+$/);
expect(refined.column).toBe("triage");
expect(refined.title).toBe(`Refinement: ${task.id}`);
});
it("creates refinement from in-review task", async () => {
const task = await store.createTask({ description: "Original task" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
const refined = await store.refineTask(task.id, "Need improvements");
expect(refined.column).toBe("triage");
expect(refined.title).toBe(`Refinement: ${task.id}`);
});
it("throws error when refining task in triage", async () => {
const task = await store.createTask({ description: "Original task" });
// Task starts in triage
await expect(store.refineTask(task.id, "Feedback")).rejects.toThrow("must be in 'done' or 'in-review'");
});
it("throws error when refining task in todo", async () => {
const task = await store.createTask({ description: "Original task" });
await store.moveTask(task.id, "todo");
await expect(store.refineTask(task.id, "Feedback")).rejects.toThrow("must be in 'done' or 'in-review'");
});
it("throws error when refining task in in-progress", async () => {
const task = await store.createTask({ description: "Original task" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await expect(store.refineTask(task.id, "Feedback")).rejects.toThrow("must be in 'done' or 'in-review'");
});
it("throws error when feedback is empty", async () => {
const task = await store.createTask({ description: "Original task" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.moveTask(task.id, "done");
await expect(store.refineTask(task.id, "")).rejects.toThrow("Feedback is required");
});
it("throws error when feedback is whitespace only", async () => {
const task = await store.createTask({ description: "Original task" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.moveTask(task.id, "done");
await expect(store.refineTask(task.id, " ")).rejects.toThrow("Feedback is required");
});
it("sets correct title format with original title", async () => {
const task = await store.createTask({ title: "My Feature", description: "Original task" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.moveTask(task.id, "done");
const refined = await store.refineTask(task.id, "Add more tests");
expect(refined.title).toBe("Refinement: My Feature");
});
it("sets correct title format without original title (uses ID)", async () => {
const task = await store.createTask({ description: "Original task" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.moveTask(task.id, "done");
const refined = await store.refineTask(task.id, "Add more tests");
expect(refined.title).toBe(`Refinement: ${task.id}`);
});
it("description includes feedback and refines reference", async () => {
const task = await store.createTask({ description: "Original task" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.moveTask(task.id, "done");
const refined = await store.refineTask(task.id, "Fix the edge case handling");
expect(refined.description).toBe(`Fix the edge case handling\n\nRefines: ${task.id}`);
});
it("sets dependency on original task", async () => {
const task = await store.createTask({ description: "Original task" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.moveTask(task.id, "done");
const refined = await store.refineTask(task.id, "Need improvements");
expect(refined.dependencies).toEqual([task.id]);
});
it("adds log entry for refinement creation", async () => {
const task = await store.createTask({ description: "Original task" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.moveTask(task.id, "done");
const refined = await store.refineTask(task.id, "Need improvements");
expect(refined.log).toHaveLength(1);
expect(refined.log[0].action).toBe(`Created as refinement of ${task.id}`);
});
it("emits task:created event", async () => {
const task = await store.createTask({ description: "Original task" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.moveTask(task.id, "done");
const events: any[] = [];
store.on("task:created", (t) => events.push(t));
const refined = await store.refineTask(task.id, "Need improvements");
expect(events).toHaveLength(1);
expect(events[0].id).toBe(refined.id);
});
it("copies attachments from original task", async () => {
const task = await store.createTask({ description: "Original task" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.moveTask(task.id, "done");
// Add an attachment
await store.addAttachment(task.id, "test.png", Buffer.from("fake image"), "image/png");
const refined = await store.refineTask(task.id, "Need improvements");
expect(refined.attachments).toHaveLength(1);
expect(refined.attachments![0].originalName).toBe("test.png");
expect(refined.attachments![0].mimeType).toBe("image/png");
});
it("copies attachment files to new task directory", async () => {
const task = await store.createTask({ description: "Original task" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.moveTask(task.id, "done");
// Add an attachment
await store.addAttachment(task.id, "test.png", Buffer.from("fake image data"), "image/png");
const refined = await store.refineTask(task.id, "Need improvements");
// Verify file exists in new task directory
const attachDir = join(rootDir, ".kb", "tasks", refined.id, "attachments");
const files = await readdir(attachDir);
expect(files.length).toBe(1);
// Verify content was copied
const content = await readFile(join(attachDir, files[0]));
expect(content.toString()).toBe("fake image data");
});
it("works when source has no attachments", async () => {
const task = await store.createTask({ description: "Original task" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.moveTask(task.id, "done");
const refined = await store.refineTask(task.id, "Need improvements");
expect(refined.attachments).toBeUndefined();
});
it("resets execution state (no steps, no worktree, etc.)", async () => {
const task = await store.createTask({ description: "Original task" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.moveTask(task.id, "done");
const refined = await store.refineTask(task.id, "Need improvements");
expect(refined.steps).toEqual([]);
expect(refined.currentStep).toBe(0);
expect(refined.worktree).toBeUndefined();
expect(refined.status).toBeUndefined();
});
it("creates PROMPT.md for the refinement", async () => {
const task = await store.createTask({ description: "Original task" });
await store.moveTask(task.id, "todo");
await store.moveTask(task.id, "in-progress");
await store.moveTask(task.id, "in-review");
await store.moveTask(task.id, "done");
const refined = await store.refineTask(task.id, "Need improvements");
const detail = await store.getTask(refined.id);
expect(detail.prompt).toContain(`Refinement: ${task.id}`);
expect(detail.prompt).toContain("Need improvements");
expect(detail.prompt).toContain(`Refines: ${task.id}`);
});
it("throws ENOENT when source task does not exist", async () => {
await expect(store.refineTask("KB-999", "Feedback")).rejects.toThrow();
});
});
// ── Archive/Unarchive Tests ──────────────────────────────────────

View File

@@ -263,6 +263,80 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
return newTask;
}
/**
* Create a refinement task from a completed or in-review task.
* The new task is created in triage with a dependency on the original task.
* Validates the original is in 'done' or 'in-review' column.
*/
async refineTask(id: string, feedback: string): Promise<Task> {
// Read the source task with its prompt
const sourceTask = await this.getTask(id);
// Validate task is in done or in-review column
if (sourceTask.column !== "done" && sourceTask.column !== "in-review") {
throw new Error(
`Cannot refine ${id}: task is in '${sourceTask.column}', must be in 'done' or 'in-review'`,
);
}
// Validate feedback is not empty
if (!feedback?.trim()) {
throw new Error("Feedback is required and cannot be empty");
}
// Allocate a new ID
const newId = await this.allocateId();
const now = new Date().toISOString();
// Create new refinement task
const newTask: Task = {
id: newId,
title: `Refinement: ${sourceTask.title || sourceTask.id}`,
description: `${feedback.trim()}\n\nRefines: ${id}`,
column: "triage",
dependencies: [id], // Refinement depends on the original being complete
steps: [], // Reset execution state
currentStep: 0,
log: [{ timestamp: now, action: `Created as refinement of ${id}` }],
columnMovedAt: now,
createdAt: now,
updatedAt: now,
// Copy attachments from original for context (defensive copy)
attachments: sourceTask.attachments ? [...sourceTask.attachments] : undefined,
};
const newDir = this.taskDir(newId);
await mkdir(newDir, { recursive: true });
await this.atomicWriteTaskJson(newDir, newTask);
// Create a PROMPT.md for the refinement
const heading = newTask.title;
const prompt = `# ${heading}\n\n${newTask.description}\n`;
await writeFile(join(newDir, "PROMPT.md"), prompt);
// Copy attachments from source if any
if (sourceTask.attachments && sourceTask.attachments.length > 0) {
const sourceAttachDir = join(this.taskDir(id), "attachments");
const targetAttachDir = join(newDir, "attachments");
await mkdir(targetAttachDir, { recursive: true });
for (const attachment of sourceTask.attachments) {
const sourcePath = join(sourceAttachDir, attachment.filename);
const targetPath = join(targetAttachDir, attachment.filename);
if (existsSync(sourcePath)) {
const content = await readFile(sourcePath);
await writeFile(targetPath, content);
}
}
}
// Update cache if watcher is active
if (this.watcher) this.taskCache.set(newId, { ...newTask });
this.emit("task:created", newTask);
return newTask;
}
/**
* Read a task's JSON and prompt content.
*