diff --git a/README.md b/README.md index 10a0e76..60d3edd 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 +- **7 LLM-callable tools** — `TaskCreate` (single + batch), `TaskList`, `TaskGet`, `TaskUpdate`, `TaskOutput`, `TaskStop`, `TaskExecute` - **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 injected into the upcoming LLM request (via the `context` hook, transient and never persisted) when task tools haven't been used recently, or when a task is left stuck `in_progress` after a text-only turn. Shaped after Claude Code's todo reminders — an empty-list nudge or a JSON echo of the current list (capped at 10 tasks) - **Prompt guidelines** — workflow contract encoded in tool descriptions, nudging the LLM at the point of tool use @@ -90,20 +90,55 @@ See [Writing your own sort order](CUSTOMIZING.md#writing-your-own-sort-order) fo ### `TaskCreate` -Create a structured task. Used proactively for complex multi-step work. +Create one or more structured tasks. Pass `subject` and `description` for a single task, or a `batch` array for batch creation. + +**Single task** (backward compatible): | Parameter | Type | Required | Description | |-----------|------|----------|-------------| -| `subject` | string | yes | Brief imperative title | -| `description` | string | yes | Detailed context and acceptance criteria | +| `subject` | string | yes* | Brief imperative title | +| `description` | string | yes* | Detailed context and acceptance criteria | | `activeForm` | string | no | Present continuous form for spinner (e.g., "Running tests") | | `agentType` | string | no | Agent type for subagent execution (e.g., `"general-purpose"`, `"Explore"`) | | `metadata` | object | no | Arbitrary key-value pairs | +*\*Required when not using `batch`.* + +**Batch creation** (via `batch` array): + +| Parameter | Type | Required | Description | +|-----------|------|----------|-------------| +| `batch` | array | yes* | Array of task items (min 1 item). Mutually exclusive with `subject`/`description`. | +| `batch[].subject` | string | yes | Brief imperative title | +| `batch[].description` | string | yes | Detailed context and acceptance criteria | +| `batch[].activeForm` | string | no | Present continuous form for spinner | +| `batch[].agentType` | string | no | Agent type for subagent execution via TaskExecute | +| `batch[].metadata` | object | no | Arbitrary key-value pairs | + +*\*Required when not using `subject`/`description`. You cannot mix `batch` with `subject`/`description`.* + ``` → Task #1 created successfully: Fix authentication bug ``` +Batch example: +```json +{ + "batch": [ + { "subject": "Design the API", "description": "Decide the shape" }, + { "subject": "Implement the handler", "description": "Write the code" }, + { "subject": "Write tests", "description": "Cover edge cases" } + ] +} +``` + +``` +→ 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. @@ -357,7 +392,7 @@ If [`pi-subagents`](https://github.com/tintinweb/pi-subagents) is not installed, src/ ├── index.ts # Extension entry: 7 tools + /tasks command + widget + subagent integration ├── types.ts # Task, TaskStatus, BackgroundProcess types -├── task-store.ts # File-backed store with CRUD, dependencies, locking +├── task-store.ts # File-backed store with CRUD, batch creation, dependencies, locking ├── auto-clear.ts # Turn-based auto-clearing of completed tasks (AutoClearManager) ├── tasks-config.ts # Global defaults and project override persistence ├── process-tracker.ts # Background process output buffering and stop diff --git a/src/index.ts b/src/index.ts index 2025bce..0d848b3 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2,7 +2,7 @@ * @tintinweb/pi-tasks — A pi extension providing Claude Code-style task tracking and coordination. * * Tools: - * TaskCreate — Create a structured task + * TaskCreate — Create one or more structured tasks (single or batch) * TaskList — List all tasks with status * TaskGet — Get full task details * TaskUpdate — Update task fields, status, dependencies @@ -620,6 +620,7 @@ Skip using this tool when: NOTE that you should not use this tool if there is only one trivial task to do. In this case you are better off just doing the task directly. + ## Task Fields - **subject**: A brief, actionable title in imperative form (e.g., "Fix authentication bug in login flow") @@ -636,16 +637,29 @@ All tasks are created with status \`pending\`. - Check TaskList first to avoid creating duplicate tasks - Include \`agentType\` (e.g., "general-purpose", "Explore") to mark tasks for subagent execution via TaskExecute`, promptGuidelines: [ - "When working on complex multi-step tasks, use TaskCreate to track progress and TaskUpdate to update status.", + "When working with complex multi-step tasks, use TaskCreate to track progress and TaskUpdate to update status.", "Mark tasks as in_progress before starting work and completed when done.", "Use TaskList to check for available work after completing a task.", + "Prefer using the `batch` array to create multiple tasks at once instead of calling TaskCreate repeatedly.", ], parameters: 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" }), + // Single-task parameters (backward compatible) + subject: Type.Optional(Type.String({ description: "A brief title for the task. Use this for a single task, or use `batch` for multiple." })), + description: Type.Optional(Type.String({ description: "A detailed description of what needs to be done. Use this for a single task, or use `batch` for multiple." })), activeForm: Type.Optional(Type.String({ description: "Present continuous form shown in spinner when in_progress (e.g., 'Running tests')" })), agentType: Type.Optional(Type.String({ description: "Agent type for subagent execution (e.g., 'general-purpose', 'Explore'). Tasks with agentType can be started via TaskExecute." })), metadata: Type.Optional(Type.Record(Type.String(), Type.Any(), { description: "Arbitrary metadata to attach to the task" })), + // Batch parameter — pass an array to create multiple tasks in one call + batch: Type.Optional(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 task items to create in one call. Mutually exclusive with subject/description.", minItems: 1 }, + )), }), execute(_toolCallId, params, _signal, _onUpdate, _ctx) { @@ -653,7 +667,40 @@ All tasks are created with status \`pending\`. // cannot be relied on for that: they only tick at `turn_start`, so a run that ends // right after its last completion freezes one mid-count. autoClear.startNewBatch(); - const meta = params.metadata ?? {}; + + const hasBatch = params.batch && params.batch.length > 0; + const hasScalar = params.subject !== undefined || params.description !== undefined; + + if (hasBatch && hasScalar) { + return Promise.resolve(textResult("Error: Cannot use `batch` array and `subject`/`description` together. Use one or the other.")); + } + + if (hasBatch) { + // Batch creation + const items = params.batch!.map(t => { + const meta = t.metadata ? { ...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, + } as { subject: string; description: string; activeForm?: string; metadata?: Record }; + }); + 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"))); + } + + // Single-task creation (backward compatible) + if (!params.subject || !params.description) { + return Promise.resolve(textResult("Error: `subject` and `description` are required when not using `batch`. Provide both, or use `batch` for batch creation.")); + } + const meta = params.metadata ? { ...params.metadata } : {}; if (params.agentType) meta.agentType = params.agentType; const task = store.create(params.subject, params.description, params.activeForm, Object.keys(meta).length > 0 ? meta : undefined); widget.update(); diff --git a/src/task-store.ts b/src/task-store.ts index 27731b6..0037e33 100644 --- a/src/task-store.ts +++ b/src/task-store.ts @@ -197,6 +197,32 @@ export class TaskStore { }); } + /** Create multiple tasks in a single atomic lock. Assigns sequential IDs. */ + 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; + }); + } + get(id: string): Task | undefined { if (this.filePath) this.load(); return this.tasks.get(id); diff --git a/test/subagent-integration.test.ts b/test/subagent-integration.test.ts index efc68c1..f55ff7e 100644 --- a/test/subagent-integration.test.ts +++ b/test/subagent-integration.test.ts @@ -1159,3 +1159,148 @@ describe("Cascade data injection (buildTaskPrompt)", () => { expect(bPrompt).not.toContain("Prerequisite task results"); }); }); + +// ────────────────────────────────────────────────────────────────────────────── +// TaskCreate batch tests +// ────────────────────────────────────────────────────────────────────────────── + +describe("TaskCreate batch mode", () => { + let mock: ReturnType; + + beforeEach(() => { + mock = mockPi(); + initExtension(mock.pi as any); + }); + + it("creates multiple tasks via tasks array", async () => { + const result = await mock.executeTool("TaskCreate", { + batch: [ + { 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 batch tasks appear in TaskList", async () => { + await mock.executeTool("TaskCreate", { + batch: [ + { 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 for batch tasks", async () => { + await mock.executeTool("TaskCreate", { + batch: [ + { 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("batch IDs are sequential after prior TaskCreate calls", async () => { + await mock.executeTool("TaskCreate", { + subject: "Existing", + description: "Already here", + }); + + const result = await mock.executeTool("TaskCreate", { + batch: [ + { 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 in array", async () => { + const result = await mock.executeTool("TaskCreate", { + batch: [{ subject: "Just one", description: "Lonely task" }], + }); + + expect(result.content[0].text).toContain("Created 1 task:"); + }); + + it("rejects mixing tasks array with subject/description", async () => { + const result = await mock.executeTool("TaskCreate", { + batch: [{ subject: "A", description: "D" }], + subject: "Conflicting", + description: "Should not work", + }); + + expect(result.content[0].text).toContain("Error"); + expect(result.content[0].text).toContain("Cannot use"); + }); + + it("requires subject and description when not using tasks array", async () => { + const result = await mock.executeTool("TaskCreate", { + subject: "Missing description", + }); + + expect(result.content[0].text).toContain("Error"); + expect(result.content[0].text).toContain("required"); + }); + + it("backward-compatible single task creation still works", async () => { + const result = await mock.executeTool("TaskCreate", { + subject: "Fix auth bug", + description: "Fix the login flow", + activeForm: "Fixing auth bug", + }); + + expect(result.content[0].text).toContain("Task #1 created successfully: Fix auth bug"); + }); + + it("preserves activeForm and metadata in batch tasks", async () => { + await mock.executeTool("TaskCreate", { + batch: [ + { subject: "A", description: "D", activeForm: "Doing A", metadata: { priority: "high" } }, + { subject: "B", description: "D" }, + ], + }); + + const taskA = await mock.executeTool("TaskGet", { taskId: "1" }); + expect(taskA.content[0].text).toContain("priority"); + expect(taskA.content[0].text).toContain("high"); + + const taskB = await mock.executeTool("TaskGet", { taskId: "2" }); + const taskBText = taskB.content[0].text as string; + expect(taskBText).toContain("B"); + // Task B should not have priority metadata + expect(taskBText).not.toContain("priority"); + }); + + it("batch creation is atomic — all tasks appear after call", async () => { + await mock.executeTool("TaskCreate", { + batch: [ + { subject: "One", description: "1" }, + { subject: "Two", description: "2" }, + { subject: "Three", description: "3" }, + ], + }); + + const list = await mock.executeTool("TaskList", {}); + const text = list.content[0].text as string; + expect(text).toContain("One"); + expect(text).toContain("Two"); + expect(text).toContain("Three"); + }); +}); diff --git a/test/task-store.test.ts b/test/task-store.test.ts index 29e5d98..fee2724 100644 --- a/test/task-store.test.ts +++ b/test/task-store.test.ts @@ -628,3 +628,82 @@ describe("TaskStore (malformed files)", () => { expect(new TaskStore(file).create("Next", "d").id).toBe("42"); }); }); + +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 { /* */ } + } + }); +});