From 76d79e6b8e4750db2774c4d5426067c5f29573d5 Mon Sep 17 00:00:00 2001 From: Daniel Diaz Date: Fri, 1 May 2026 15:51:11 +0200 Subject: [PATCH] feat: add TaskCreateMany tool for batch task creation --- README.md | 22 +++++++- src/index.ts | 89 ++++++++++++++++++++++++++++--- src/task-store.ts | 25 +++++++++ test/subagent-integration.test.ts | 84 +++++++++++++++++++++++++++++ test/task-store.test.ts | 79 +++++++++++++++++++++++++++ 5 files changed, 290 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index cdb7bc0..257fcb7 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ https://github.com/user-attachments/assets/1d0ee87a-e0a5-4bfa-a9b9-2f9144cb905b ## Features -- **7 LLM-callable tools** — `TaskCreate`, `TaskList`, `TaskGet`, `TaskUpdate`, `TaskOutput`, `TaskStop`, `TaskExecute` — matching Claude Code's exact tool specs and descriptions +- **8 LLM-callable tools** — `TaskCreate`, `TaskCreateMany`, `TaskList`, `TaskGet`, `TaskUpdate`, `TaskOutput`, `TaskStop`, `TaskExecute` — matching Claude Code's exact tool specs and descriptions - **Persistent widget** — live task list above the editor with `✔`/`◼`/`◻` status icons, task numbers (`#1`, `#2`, …), strikethrough for completed tasks, star spinner (`✳✽`) for active tasks with elapsed time and token counts - **System-reminder injection** — periodic `` nudges appended to tool results when task tools haven't been used recently (matches Claude Code's behavior exactly) - **Prompt guidelines** — workflow contract encoded in tool descriptions, nudging the LLM at the point of tool use @@ -71,6 +71,26 @@ Create a structured task. Used proactively for complex multi-step work. → Task #1 created successfully: Fix authentication bug ``` +### `TaskCreateMany` + +Create multiple structured tasks in a single call. More efficient than repeated `TaskCreate` calls when all tasks are known upfront. + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `tasks` | array | yes | Array of task objects (min 1) | +| `tasks[].subject` | string | yes | Brief imperative title | +| `tasks[].description` | string | yes | Detailed context and acceptance criteria | +| `tasks[].activeForm` | string | no | Present continuous form for spinner | +| `tasks[].agentType` | string | no | Agent type for subagent execution | +| `tasks[].metadata` | object | no | Arbitrary key-value pairs | + +``` +→ Created 3 tasks: + #1 Design the API + #2 Implement the handler + #3 Write tests +``` + ### `TaskList` List all tasks with status, owner, and blocked-by info. diff --git a/src/index.ts b/src/index.ts index 5bdd4de..0382d7d 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,7 +2,8 @@ * @tintinweb/pi-tasks — A pi extension providing Claude Code-style task tracking and coordination. * * Tools: - * TaskCreate — Create a structured task + * TaskCreate — Create a structured task + * TaskCreateMany — Create multiple tasks in one call * TaskList — List all tasks with status * TaskGet — Get full task details * TaskUpdate — Update task fields, status, dependencies @@ -39,7 +40,7 @@ function textResult(msg: string) { } /** Task tool names — used to detect task tool usage for reminder suppression. */ -const TASK_TOOL_NAMES = new Set(["TaskCreate", "TaskList", "TaskGet", "TaskUpdate", "TaskOutput", "TaskStop", "TaskExecute"]); +const TASK_TOOL_NAMES = new Set(["TaskCreate", "TaskCreateMany", "TaskList", "TaskGet", "TaskUpdate", "TaskOutput", "TaskStop", "TaskExecute"]); /** How many turns without task tool usage before injecting a reminder. */ const REMINDER_INTERVAL = 4; @@ -463,7 +464,79 @@ All tasks are created with status \`pending\`. }); // ────────────────────────────────────────────────── - // Tool 2: TaskList + // Tool 2: TaskCreateMany + // ────────────────────────────────────────────────── + + pi.registerTool({ + name: "TaskCreateMany", + label: "TaskCreateMany", + description: `Use this tool to create multiple structured tasks in a single call. Prefer this over repeated TaskCreate calls when you know all tasks upfront. + +## When to Use This Tool + +- When planning a multi-step effort and all tasks are known at once +- When the user provides a list of things to be done (numbered or comma-separated) +- When you want to batch-create tasks without repeated round-trips + +## When NOT to Use This Tool + +Skip using this tool when: +- You only have one task to create (use TaskCreate instead) +- The task list depends on the output of prior work + +## Task Fields (per item) + +- **subject**: A brief, actionable title in imperative form +- **description**: Detailed description of what needs to be done +- **activeForm** (optional): Present continuous form for the spinner (e.g., "Fixing bug") +- **agentType** (optional): Agent type for subagent execution via TaskExecute +- **metadata** (optional): Arbitrary key-value pairs + +## Tips + +- Use TaskUpdate afterwards to set up dependencies (blocks/blockedBy) between the created tasks +- IDs are assigned sequentially in the order tasks appear in the array`, + promptGuidelines: [ + "Use TaskCreateMany when you have multiple tasks to create at once — it is more efficient than calling TaskCreate in a loop.", + "After bulk creation, use TaskUpdate to wire up any blocks/blockedBy dependencies between tasks.", + ], + parameters: Type.Object({ + tasks: Type.Array( + Type.Object({ + subject: Type.String({ description: "A brief title for the task" }), + description: Type.String({ description: "A detailed description of what needs to be done" }), + activeForm: Type.Optional(Type.String({ description: "Present continuous form shown in spinner when in_progress" })), + agentType: Type.Optional(Type.String({ description: "Agent type for subagent execution via TaskExecute" })), + metadata: Type.Optional(Type.Record(Type.String(), Type.Any(), { description: "Arbitrary metadata to attach to the task" })), + }), + { description: "Array of tasks to create", minItems: 1 }, + ), + }), + + execute(_toolCallId, params, _signal, _onUpdate, _ctx) { + autoClear.resetBatchCountdown(); + const items = params.tasks.map(t => { + const meta = t.metadata ?? {}; + if (t.agentType) meta.agentType = t.agentType; + return { + subject: t.subject, + description: t.description, + activeForm: t.activeForm, + metadata: Object.keys(meta).length > 0 ? meta : undefined, + }; + }); + const created = store.createMany(items); + widget.update(); + const lines = [`Created ${created.length} task${created.length === 1 ? "" : "s"}:`]; + for (const task of created) { + lines.push(` #${task.id} ${task.subject}`); + } + return Promise.resolve(textResult(lines.join("\n"))); + }, + }); + + // ────────────────────────────────────────────────── + // Tool 3: TaskList // ────────────────────────────────────────────────── pi.registerTool({ @@ -529,7 +602,7 @@ Use TaskGet with a specific task ID to view full details including description a }); // ────────────────────────────────────────────────── - // Tool 3: TaskGet + // Tool 4: TaskGet // ────────────────────────────────────────────────── pi.registerTool({ @@ -600,7 +673,7 @@ Returns full task details: }); // ────────────────────────────────────────────────── - // Tool 4: TaskUpdate + // Tool 5: TaskUpdate // ────────────────────────────────────────────────── pi.registerTool({ @@ -729,7 +802,7 @@ Set up task dependencies: }); // ────────────────────────────────────────────────── - // Tool 5: TaskOutput + // Tool 6: TaskOutput // ────────────────────────────────────────────────── pi.registerTool({ @@ -805,7 +878,7 @@ Set up task dependencies: }); // ────────────────────────────────────────────────── - // Tool 6: TaskStop + // Tool 7: TaskStop // ────────────────────────────────────────────────── pi.registerTool({ @@ -856,7 +929,7 @@ Set up task dependencies: }); // ────────────────────────────────────────────────── - // Tool 7: TaskExecute + // Tool 8: TaskExecute // ────────────────────────────────────────────────── pi.registerTool({ diff --git a/src/task-store.ts b/src/task-store.ts index c2f3263..aede826 100644 --- a/src/task-store.ts +++ b/src/task-store.ts @@ -108,6 +108,31 @@ export class TaskStore { } } + createMany(items: Array<{ subject: string; description: string; activeForm?: string; metadata?: Record }>): Task[] { + return this.withLock(() => { + const now = Date.now(); + const created: Task[] = []; + for (const item of items) { + const task: Task = { + id: String(this.nextId++), + subject: item.subject, + description: item.description, + status: "pending", + activeForm: item.activeForm, + owner: undefined, + metadata: item.metadata ?? {}, + blocks: [], + blockedBy: [], + createdAt: now, + updatedAt: now, + }; + this.tasks.set(task.id, task); + created.push(task); + } + return created; + }); + } + create(subject: string, description: string, activeForm?: string, metadata?: Record): Task { return this.withLock(() => { const now = Date.now(); diff --git a/test/subagent-integration.test.ts b/test/subagent-integration.test.ts index 2bdb36c..810cf10 100644 --- a/test/subagent-integration.test.ts +++ b/test/subagent-integration.test.ts @@ -994,3 +994,87 @@ describe("Cascade data injection (buildTaskPrompt)", () => { expect(bPrompt).not.toContain("Prerequisite task results"); }); }); + +// ────────────────────────────────────────────────────────────────────────────── +// TaskCreateMany tool tests +// ────────────────────────────────────────────────────────────────────────────── + +describe("TaskCreateMany", () => { + let mock: ReturnType; + + beforeEach(() => { + mock = mockPi(); + initExtension(mock.pi as any); + }); + + it("is registered as a tool", () => { + expect(mock.tools.has("TaskCreateMany")).toBe(true); + }); + + it("creates multiple tasks and returns a summary", async () => { + const result = await mock.executeTool("TaskCreateMany", { + tasks: [ + { subject: "Step one", description: "Do the first thing" }, + { subject: "Step two", description: "Do the second thing" }, + { subject: "Step three", description: "Do the third thing" }, + ], + }); + + const text = result.content[0].text as string; + expect(text).toContain("Created 3 tasks"); + expect(text).toContain("#1 Step one"); + expect(text).toContain("#2 Step two"); + expect(text).toContain("#3 Step three"); + }); + + it("created tasks appear in TaskList", async () => { + await mock.executeTool("TaskCreateMany", { + tasks: [ + { subject: "Alpha", description: "A" }, + { subject: "Beta", description: "B" }, + ], + }); + + const list = await mock.executeTool("TaskList", {}); + const text = list.content[0].text as string; + expect(text).toContain("Alpha"); + expect(text).toContain("Beta"); + }); + + it("supports agentType stored in metadata", async () => { + await mock.executeTool("TaskCreateMany", { + tasks: [ + { subject: "Agent task", description: "Run me", agentType: "general-purpose" }, + ], + }); + + const details = await mock.executeTool("TaskGet", { taskId: "1" }); + expect(details.content[0].text).toContain("general-purpose"); + }); + + it("IDs are sequential after prior TaskCreate calls", async () => { + await mock.executeTool("TaskCreate", { + subject: "Existing", + description: "Already here", + }); + + const result = await mock.executeTool("TaskCreateMany", { + tasks: [ + { subject: "Bulk A", description: "D" }, + { subject: "Bulk B", description: "D" }, + ], + }); + + const text = result.content[0].text as string; + expect(text).toContain("#2 Bulk A"); + expect(text).toContain("#3 Bulk B"); + }); + + it("returns singular wording for a single task", async () => { + const result = await mock.executeTool("TaskCreateMany", { + tasks: [{ subject: "Just one", description: "Lonely task" }], + }); + + expect(result.content[0].text).toContain("Created 1 task:"); + }); +}); diff --git a/test/task-store.test.ts b/test/task-store.test.ts index ecb7b9a..596e6a8 100644 --- a/test/task-store.test.ts +++ b/test/task-store.test.ts @@ -414,3 +414,82 @@ describe("TaskStore (absolute path)", () => { expect(raw.tasks).toHaveLength(2); }); }); + +describe("TaskStore createMany", () => { + let store: TaskStore; + + beforeEach(() => { + store = new TaskStore(); + }); + + it("creates multiple tasks with sequential IDs", () => { + const created = store.createMany([ + { subject: "Task A", description: "Desc A" }, + { subject: "Task B", description: "Desc B" }, + { subject: "Task C", description: "Desc C" }, + ]); + + expect(created).toHaveLength(3); + expect(created.map(t => t.id)).toEqual(["1", "2", "3"]); + expect(created.map(t => t.subject)).toEqual(["Task A", "Task B", "Task C"]); + expect(store.list()).toHaveLength(3); + }); + + it("sets status to pending for all created tasks", () => { + const created = store.createMany([ + { subject: "A", description: "D" }, + { subject: "B", description: "D" }, + ]); + expect(created.every(t => t.status === "pending")).toBe(true); + }); + + it("preserves activeForm and metadata per task", () => { + const created = store.createMany([ + { subject: "A", description: "D", activeForm: "Doing A", metadata: { key: "val" } }, + { subject: "B", description: "D" }, + ]); + expect(created[0].activeForm).toBe("Doing A"); + expect(created[0].metadata).toEqual({ key: "val" }); + expect(created[1].activeForm).toBeUndefined(); + expect(created[1].metadata).toEqual({}); + }); + + it("continues ID counter from existing tasks", () => { + store.create("Existing", "Desc"); + const created = store.createMany([ + { subject: "Bulk A", description: "D" }, + { subject: "Bulk B", description: "D" }, + ]); + expect(created.map(t => t.id)).toEqual(["2", "3"]); + }); + + it("returns empty array for empty input", () => { + const created = store.createMany([]); + expect(created).toEqual([]); + expect(store.list()).toHaveLength(0); + }); + + it("persists bulk-created tasks in file-backed mode", () => { + const testId = `test-bulk-${Date.now()}`; + const tasksDir = join(homedir(), ".pi", "tasks"); + const filePath = join(tasksDir, `${testId}.json`); + + try { + const store1 = new TaskStore(testId); + store1.createMany([ + { subject: "Persisted A", description: "D" }, + { subject: "Persisted B", description: "D" }, + ]); + + const store2 = new TaskStore(testId); + const tasks = store2.list(); + expect(tasks).toHaveLength(2); + expect(tasks[0].subject).toBe("Persisted A"); + expect(tasks[1].subject).toBe("Persisted B"); + } finally { + try { rmSync(filePath); } catch { /* */ } + try { rmSync(filePath + ".lock"); } catch { /* */ } + try { rmSync(filePath + ".tmp"); } catch { /* */ } + } + }); +});