feat(KB-016): add task duplicate command

- Add duplicateTask() method to core store with fresh state reset
- Add CLI command handler and register duplicate subcommand
- Add POST /tasks/:id/duplicate API endpoint to dashboard
- Add comprehensive tests for store, CLI, and API routes
- Add pi extension tool for task duplication
- Add changeset for minor release bump
This commit is contained in:
gsxdsm
2026-03-29 18:22:02 -07:00
parent 64fcb5d1b1
commit f3097c7023
11 changed files with 419 additions and 2 deletions

View File

@@ -80,6 +80,7 @@ describe("kb pi extension", () => {
"kb_task_attach",
"kb_task_pause",
"kb_task_unpause",
"kb_task_duplicate",
"kb_task_import_github",
"kb_task_import_github_issue",
"kb_task_browse_github_issues",

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 } = await import("./commands/task.js");
const { runTaskCreate, runTaskList, runTaskMove, runTaskMerge, runTaskUpdate, runTaskLog, runTaskShow, runTaskAttach, runTaskPause, runTaskUnpause, runTaskImportFromGitHub, runTaskDuplicate } = await import("./commands/task.js");
const HELP = `
kb — AI-orchestrated task board
@@ -54,6 +54,7 @@ Usage:
kb task update <id> <step> <status> Update step status (pending|in-progress|done|skipped)
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 attach <id> <file> Attach a file to a task
kb task pause <id> Pause a task (stops all automation)
kb task unpause <id> Unpause a task (resumes automation)
@@ -164,6 +165,12 @@ async function main() {
await runTaskMerge(id);
break;
}
case "duplicate": {
const id = args[2];
if (!id) { console.error("Usage: kb task duplicate <id>"); process.exit(1); }
await runTaskDuplicate(id);
break;
}
case "attach": {
const id = args[2], file = args[3];
if (!id || !file) {

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 } from "./task.js";
import { runTaskShow, runTaskCreate, runTaskDuplicate } from "./task.js";
function makeTask(overrides: Record<string, unknown> = {}) {
return {
@@ -838,3 +838,57 @@ describe("runTaskImportFromGitHub", () => {
});
});
});
// --- Duplicate Tests ---
describe("runTaskDuplicate", () => {
let logSpy: ReturnType<typeof vi.spyOn>;
let errorSpy: ReturnType<typeof vi.spyOn>;
let mockDuplicateTask: ReturnType<typeof vi.fn>;
beforeEach(() => {
logSpy = vi.spyOn(console, "log").mockImplementation(() => {});
errorSpy = vi.spyOn(console, "error").mockImplementation(() => {});
mockDuplicateTask = vi.fn().mockResolvedValue({
id: "KB-002",
description: "Duplicated task",
column: "triage",
dependencies: [],
steps: [],
currentStep: 0,
log: [],
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
(TaskStore as unknown as ReturnType<typeof vi.fn>).mockImplementation(() => ({
init: vi.fn(),
duplicateTask: mockDuplicateTask,
}));
});
afterEach(() => {
vi.restoreAllMocks();
});
it("duplicates task and prints success", async () => {
await runTaskDuplicate("KB-001");
expect(mockDuplicateTask).toHaveBeenCalledOnce();
expect(mockDuplicateTask).toHaveBeenCalledWith("KB-001");
const successLine = logSpy.mock.calls.find(
(call) => typeof call[0] === "string" && call[0].includes("✓ Duplicated"),
);
expect(successLine).toBeDefined();
expect(successLine![0]).toContain("KB-001");
expect(successLine![0]).toContain("KB-002");
});
it("throws when task not found", async () => {
mockDuplicateTask.mockRejectedValueOnce(new Error("Task KB-999 not found"));
await expect(runTaskDuplicate("KB-999")).rejects.toThrow("Task KB-999 not found");
});
});

View File

@@ -286,6 +286,16 @@ export async function runTaskMove(id: string, column: string) {
console.log();
}
export async function runTaskDuplicate(id: string) {
const store = await getStore();
const newTask = await store.duplicateTask(id);
console.log();
console.log(` ✓ Duplicated ${id}${newTask.id}`);
console.log(` Path: .kb/tasks/${newTask.id}/`);
console.log();
}
export async function runTaskImportGitHubInteractive(
ownerRepo: string,
options: TaskImportOptions = {}

View File

@@ -341,6 +341,36 @@ export default function kbExtension(pi: ExtensionAPI) {
},
});
// ── kb_task_duplicate ─────────────────────────────────────────────
pi.registerTool({
name: "kb_task_duplicate",
label: "KB: Duplicate Task",
description:
"Duplicate an existing task, creating a fresh copy in triage. " +
"Copies the title and description but resets all execution state. " +
"The AI triage agent will re-specify the new task.",
promptSnippet: "Duplicate a kb task (creates copy in triage)",
promptGuidelines: [
"Use when a task needs to be re-done, split, or used as a template",
"The duplicated task will be placed in triage for re-specification",
"Dependencies, attachments, and execution state are NOT copied",
],
parameters: Type.Object({
id: Type.String({ description: "Source task ID to duplicate (e.g. KB-001)" }),
}),
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
const store = await getStore(ctx.cwd);
const newTask = await store.duplicateTask(params.id);
return {
content: [{ type: "text", text: `Duplicated ${params.id}${newTask.id}` }],
details: { sourceId: params.id, newTaskId: newTask.id },
};
},
});
// ── kb_task_import_github ─────────────────────────────────────────
pi.registerTool({