From dad0915d62ce1d9970272b6cb17b2442892c0322 Mon Sep 17 00:00:00 2001 From: kim Date: Fri, 21 Aug 2026 14:07:16 +0900 Subject: [PATCH] feat: structured task system with dependency graph (task_create/list/get/update) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces the flat todo_write approach for non-trivial multi-step work with a structured, incrementally-updated task store — dependencies, ownership, status, metadata — ported from origin/v0.6.0 and adapted to the local Session/ctx. - src/tools/task.ts: TaskStore (in-memory, per-session) + four read-only tools: task_create (returns id), task_list (summaries), task_get (full details), task_update (status pending|in_progress|completed|deleted, rename, claim via owner, addBlocks/addBlockedBy dependency edges, merge-patch metadata — null deletes a key). Self-refs/unknown ids ignored; direct 2-cycles skipped; delete prunes dangling refs. Schemas declared before use (fixes a TDZ in the v0.6.0 version). todo_write kept alongside for simpler cases. - types.ts: ctx.taskStore. session.ts: TaskStore on every Session. loop.ts: expose session.taskStore in the tool ctx + on sub-agent sessions. - tools/index.ts: register the four task tools. - task.test.ts: 9 cases (create/list/get/update status, dependency edges both ways, self-ref/unknown/2-cycle guards, delete+prune, metadata merge-patch, absent-store error). - README: document the structured tasks. Verified: typecheck clean, build 275.10 KB, 301 tests pass (+9). --- README.md | 4 +- src/agent/loop.ts | 3 + src/agent/session.ts | 6 + src/tools/index.ts | 5 + src/tools/task.test.ts | 116 ++++++++++++++++++ src/tools/task.ts | 272 +++++++++++++++++++++++++++++++++++++++++ src/tools/types.ts | 4 + 7 files changed, 408 insertions(+), 2 deletions(-) create mode 100644 src/tools/task.test.ts create mode 100644 src/tools/task.ts diff --git a/README.md b/README.md index c914f94..2affa9e 100644 --- a/README.md +++ b/README.md @@ -90,9 +90,9 @@ locode is a full-screen terminal app built with [Ink](https://github.com/vadimde - **Ctrl+F**: open and focus a file panel docked to the right of the chat (hidden by default); press again to close it. It has two tabs — **Files**, the project's collapsible file tree (directories in cyan, same `node_modules`/`.git`/`dist` exclusions as `@` mentions), and **Activity**, the files `read_file`/`write_file`/`edit_file` have touched so far this session, most recent first, with a status glyph (`·` read, `+` written, `~` edited) and a repeat count. **Ctrl+G** switches between the two tabs. While the panel is focused, `↑`/`↓` move the selection (auto-scrolling to keep it in view), `↵`/`←`/`→` expand or collapse the selected folder, and **Esc** hands keyboard focus back to the chat input without closing the panel — typing is disabled while the panel has focus, so the same arrow key doesn't simultaneously recall chat history. - **Backends**: `--backend ollama` (default) or `--backend lmstudio`, or `--base-url ` for anything else that speaks the same API. -- **Tools**: `read_file`, `list_files`, `grep`, `definition`, `references`, `diagnostics`, `web_search`, `web_fetch`, `git_status`, `bash_output`, `todo_write` run automatically. `write_file`, `edit_file`, `multi_edit`, `bash`, `bash_kill`, and `git_commit` show a diff/preview in a bordered box and ask you to pick Yes / Yes-always-this-session / No with the arrow keys before running. +- **Tools**: `read_file`, `list_files`, `grep`, `definition`, `references`, `diagnostics`, `web_search`, `web_fetch`, `git_status`, `bash_output`, `todo_write`, `task_create`, `task_list`, `task_get`, `task_update` run automatically. `write_file`, `edit_file`, `multi_edit`, `bash`, `bash_kill`, and `git_commit` show a diff/preview in a bordered box and ask you to pick Yes / Yes-always-this-session / No with the arrow keys before running. - **Permission modes**: `default` (ask before every mutating tool), `plan` (research only — every mutating tool is blocked outright, no prompt; the model is expected to describe what it would do in its final answer instead), `auto-edit` (file edits auto-approved, `bash`/`git_commit` still ask), `auto-accept` (everything auto-approved — use with care). Cycle with `Shift+Tab` or set directly with `/perm `. -- **Task checklists**: for multi-step work the model can call `todo_write` to show a live checklist (`☐`/`◐`/`☑`) in the transcript instead of silently working through a list you can't see progress on. +- **Structured tasks**: for multi-step work the model can call `task_create`/`task_list`/`task_get`/`task_update` to track units of work with a dependency graph (`blocks`/`blockedBy`), ownership (`owner`), status, and free-form metadata — created incrementally rather than replaced wholesale. The older flat `todo_write` live checklist (`☐`/`◐`/`☑`) remains for simpler cases. - **Project instructions**: a `CLAUDE.md` (or `AGENTS.md`) file in the project root is automatically read at session start and folded into the system prompt — put repo-specific conventions there and every session picks them up without being told. - **Images**: `read_file` returns image files (png, jpg, jpeg, gif, webp, bmp — up to 5MB) as actual image content instead of trying to decode them as text, so vision-capable models can see them when the model itself calls the tool. To attach a file or image to your own message directly, use `/import [caption]`. - **`@` file mentions**: type `@` in the chat input to open a fuzzy file picker (searches the whole project, skipping `node_modules`/`.git`/`dist`) — keep typing to filter, `↑`/`↓` to navigate, `Tab` (or `Enter`) to insert the highlighted path. Any `@path` left in your message when you hit `Enter` for real is resolved against disk and attached to that message (text inlined, images attached as image content) — a stray `@` that isn't an actual file (e.g. an email address) is left as plain text. diff --git a/src/agent/loop.ts b/src/agent/loop.ts index 275e243..d3e446a 100644 --- a/src/agent/loop.ts +++ b/src/agent/loop.ts @@ -15,6 +15,7 @@ import { resolveToolCall } from "../toolcalling/nativeAdapter.js"; import { resolveToolInvocation, runTool, type ResolvedToolCall } from "../toolcalling/resolve.js"; import { repairPartialJson } from "../toolcalling/partialJson.js"; import { notifyFileChanged } from "../codeintel/lspManager.js"; +import { TaskStore } from "../tools/task.js"; import { formatCallLabel, summarizeToolResult } from "../ui/toolSummary.js"; import { estimateTokens } from "../utils/tokens.js"; import { runHooksForEvent } from "../hooks/runner.js"; @@ -511,6 +512,7 @@ async function gateAndRun( session.todos = todos; emit({ type: "todos_update", todos }); }, + taskStore: session.taskStore, }; // PreToolUse fires before permission modes apply — a hook's block can't be bypassed by @@ -717,6 +719,7 @@ async function runSubAgentTurn( // Independent from the parent's — a sub-agent runs headless (see class doc above), so its own // checklist has nowhere to render even if it called todo_write. todos: [], + taskStore: new TaskStore(), // SHARED with the parent (not independent) — this is the whole point of the mutation gate: a // batch of parallel sub-agents all funnel their mutating calls through the parent's single // gate, so they can't race on the one permission slot or interleave filesystem writes. A diff --git a/src/agent/session.ts b/src/agent/session.ts index d2280ea..87e629a 100644 --- a/src/agent/session.ts +++ b/src/agent/session.ts @@ -9,6 +9,7 @@ import type { ConfirmFn } from "../permissions/types.js"; import { TOOLS } from "../tools/index.js"; import { buildToolSet, type ToolSet } from "../tools/toolset.js"; import type { TodoItem, ToolDef } from "../tools/types.js"; +import { TaskStore } from "../tools/task.js"; import { estimateTokens } from "../utils/tokens.js"; import { buildSystemPrompt } from "./systemPrompt.js"; @@ -86,6 +87,9 @@ export interface Session { /** Current task checklist shown to the user via the `todo_write` tool — session-scoped state * since checklist items are a snapshot of progress, not part of the model-visible conversation. */ todos: TodoItem[]; + /** Structured task store backing the task_create/list/get/update tools — session-scoped, + * in-memory (not persisted), and independent of the flat `todos` checklist. */ + taskStore: TaskStore; /** A serialized-mutation gate shared by EVERY tool call in this session — including sub-agents * spawned in parallel via the `agent` tool's `tasks` array. Parallel sub-agents share their * parent's session, so without a lock two of them could simultaneously call a mutating tool, @@ -138,6 +142,7 @@ export function createSession( mutationCommitLength: null, projectInstructions, todos: [], + taskStore: new TaskStore(), mutationGate: Promise.resolve(), }; } @@ -184,6 +189,7 @@ export function createSessionFromRecord( mutationCommitLength: null, projectInstructions, todos: [], + taskStore: new TaskStore(), mutationGate: Promise.resolve(), }; } diff --git a/src/tools/index.ts b/src/tools/index.ts index 82e87c6..c4bfe7f 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -11,6 +11,7 @@ import { listFilesTool } from "./listFiles.js"; import { notebookEditTool } from "./notebookEdit.js"; import { readFileTool } from "./readFile.js"; import { todoWriteTool } from "./todoWrite.js"; +import { taskCreateTool, taskListTool, taskGetTool, taskUpdateTool } from "./task.js"; import { webFetchTool } from "./webFetch.js"; import { webSearchTool } from "./webSearch.js"; import { writeFileTool } from "./writeFile.js"; @@ -35,6 +36,10 @@ export const TOOLS: ToolDef[] = [ bashKillTool, gitCommitTool, todoWriteTool, + taskCreateTool, + taskListTool, + taskGetTool, + taskUpdateTool, agentTool, ]; diff --git a/src/tools/task.test.ts b/src/tools/task.test.ts new file mode 100644 index 0000000..c631caa --- /dev/null +++ b/src/tools/task.test.ts @@ -0,0 +1,116 @@ +import { describe, it, expect } from "vitest"; +import { taskCreateTool, taskListTool, taskGetTool, taskUpdateTool, TaskStore } from "./task.js"; +import type { ToolContext } from "./types.js"; + +function ctxWithStore(): { ctx: ToolContext; store: TaskStore } { + const store = new TaskStore(); + return { ctx: { taskStore: store } as ToolContext, store }; +} + +describe("task tools", () => { + it("task_create creates a pending task and returns it with an id", async () => { + const { ctx, store } = ctxWithStore(); + const out = (await taskCreateTool.handler({ subject: "Fix bug", description: "details" }, ctx)) as { + id: string; + task: { status: string; blocks: string[]; blockedBy: string[] }; + }; + expect(out.id).toMatch(/^t\d+$/); + expect(out.task.status).toBe("pending"); + expect(out.task.blocks).toEqual([]); + expect(out.task.blockedBy).toEqual([]); + expect(store.list()).toHaveLength(1); + }); + + it("task_list returns all task summaries", async () => { + const { ctx } = ctxWithStore(); + await taskCreateTool.handler({ subject: "A", description: "x" }, ctx); + await taskCreateTool.handler({ subject: "B", description: "y" }, ctx); + const out = (await taskListTool.handler({}, ctx)) as { tasks: { subject: string }[] }; + expect(out.tasks.map((t) => t.subject)).toEqual(["A", "B"]); + }); + + it("task_get returns full details and errors on unknown id", async () => { + const { ctx } = ctxWithStore(); + const created = (await taskCreateTool.handler({ subject: "A", description: "long desc" }, ctx)) as { + id: string; + task: { description: string }; + }; + const got = (await taskGetTool.handler({ taskId: created.id }, ctx)) as { task: { description: string } }; + expect(got.task.description).toBe("long desc"); + const miss = (await taskGetTool.handler({ taskId: "nope" }, ctx)) as { error: string }; + expect(miss.error).toMatch(/not found/); + }); + + it("task_update sets status and marks in_progress/completed", async () => { + const { ctx } = ctxWithStore(); + const created = (await taskCreateTool.handler({ subject: "A", description: "x" }, ctx)) as { id: string }; + const upd = (await taskUpdateTool.handler({ taskId: created.id, status: "in_progress" }, ctx)) as { + task: { status: string }; + }; + expect(upd.task.status).toBe("in_progress"); + const done = (await taskUpdateTool.handler({ taskId: created.id, status: "completed" }, ctx)) as { + task: { status: string }; + }; + expect(done.task.status).toBe("completed"); + }); + + it("addBlocks/addBlockedBy link two tasks both ways", async () => { + const { ctx } = ctxWithStore(); + const a = (await taskCreateTool.handler({ subject: "A", description: "x" }, ctx)) as { id: string }; + const b = (await taskCreateTool.handler({ subject: "B", description: "y" }, ctx)) as { id: string }; + // B is blocked by A (one-directional: sets B.blockedBy, not A.blocks) + await taskUpdateTool.handler({ taskId: b.id, addBlockedBy: [a.id] }, ctx); + const bAfter = (await taskGetTool.handler({ taskId: b.id }, ctx)) as { task: { blockedBy: string[] } }; + expect(bAfter.task.blockedBy).toContain(a.id); + // Add the back-ref explicitly: A blocks B. + await taskUpdateTool.handler({ taskId: a.id, addBlocks: [b.id] }, ctx); + const aAfter = (await taskGetTool.handler({ taskId: a.id }, ctx)) as { task: { blocks: string[] } }; + expect(aAfter.task.blocks).toContain(b.id); + }); + + it("ignores self-refs, unknown ids, and direct 2-cycles", async () => { + const { ctx } = ctxWithStore(); + const a = (await taskCreateTool.handler({ subject: "A", description: "x" }, ctx)) as { id: string }; + const b = (await taskCreateTool.handler({ subject: "B", description: "y" }, ctx)) as { id: string }; + // self-ref ignored + await taskUpdateTool.handler({ taskId: a.id, addBlockedBy: [a.id] }, ctx); + expect(((await taskGetTool.handler({ taskId: a.id }, ctx)) as { task: { blockedBy: string[] } }).task.blockedBy).toEqual([]); + // unknown id ignored + await taskUpdateTool.handler({ taskId: a.id, addBlockedBy: ["zzz"] }, ctx); + expect(((await taskGetTool.handler({ taskId: a.id }, ctx)) as { task: { blockedBy: string[] } }).task.blockedBy).toEqual([]); + // B waits on A; now make A wait on B — should be skipped (2-cycle) + await taskUpdateTool.handler({ taskId: b.id, addBlockedBy: [a.id] }, ctx); + await taskUpdateTool.handler({ taskId: a.id, addBlockedBy: [b.id] }, ctx); + expect(((await taskGetTool.handler({ taskId: a.id }, ctx)) as { task: { blockedBy: string[] } }).task.blockedBy).toEqual([]); + }); + + it("status deleted removes the task and prunes dangling refs", async () => { + const { ctx } = ctxWithStore(); + const a = (await taskCreateTool.handler({ subject: "A", description: "x" }, ctx)) as { id: string }; + const b = (await taskCreateTool.handler({ subject: "B", description: "y" }, ctx)) as { id: string }; + await taskUpdateTool.handler({ taskId: b.id, addBlockedBy: [a.id] }, ctx); + const del = (await taskUpdateTool.handler({ taskId: a.id, status: "deleted" }, ctx)) as { deleted: string }; + expect(del.deleted).toBe(a.id); + // B no longer blocked by the removed A + const bAfter = (await taskGetTool.handler({ taskId: b.id }, ctx)) as { task: { blockedBy: string[] } }; + expect(bAfter.task.blockedBy).toEqual([]); + expect(((await taskListTool.handler({}, ctx)) as { tasks: unknown[] }).tasks).toHaveLength(1); + }); + + it("metadata merge-patch: set keys, null deletes", async () => { + const { ctx } = ctxWithStore(); + const a = (await taskCreateTool.handler({ subject: "A", description: "x", metadata: { k: 1 } }, ctx)) as { id: string }; + await taskUpdateTool.handler({ taskId: a.id, metadata: { k2: "v" } }, ctx); + let t = (await taskGetTool.handler({ taskId: a.id }, ctx)) as { task: { metadata: Record } }; + expect(t.task.metadata).toEqual({ k: 1, k2: "v" }); + await taskUpdateTool.handler({ taskId: a.id, metadata: { k: null } }, ctx); + t = (await taskGetTool.handler({ taskId: a.id }, ctx)) as { task: { metadata: Record } }; + expect(t.task.metadata).toEqual({ k2: "v" }); + }); + + it("returns an error when taskStore is absent", async () => { + const ctx = {} as ToolContext; + const out = (await taskCreateTool.handler({ subject: "A", description: "x" }, ctx)) as { error: string }; + expect(out.error).toMatch(/not available/); + }); +}); \ No newline at end of file diff --git a/src/tools/task.ts b/src/tools/task.ts new file mode 100644 index 0000000..8f8c086 --- /dev/null +++ b/src/tools/task.ts @@ -0,0 +1,272 @@ +import { z } from "zod"; +import type { ToolDef } from "./types.js"; + +/** A task's lifecycle state. `deleted` is only used as an update target (it removes the task); it is + * never a stored status. */ +export type TaskStatus = "pending" | "in_progress" | "completed"; + +/** A structured, trackable unit of work. Tasks form a dependency graph via `blocks`/`blockedBy` + * (each lists the other's task ids), can be owned/claimed by a named agent, and carry free-form + * metadata. Unlike the flat todo list, tasks are created and updated incrementally (not replaced + * wholesale) so dependencies and ownership can be expressed. */ +export interface Task { + id: string; + subject: string; + description: string; + /** Present-continuous label shown in a spinner while the task is in_progress (e.g. "Running tests"). */ + activeForm?: string; + status: TaskStatus; + /** Who has claimed the task (an agent name). Unset = unclaimed. */ + owner?: string; + /** Ids of tasks THIS task blocks (i.e. that depend on it). */ + blocks: string[]; + /** Ids of tasks that must be completed before this one can start. */ + blockedBy: string[]; + /** Free-form metadata; merge-patched on update (set a key to null to delete it). */ + metadata?: Record; +} + +/** A compact, list-view projection of a Task — enough to render the checklist without the long + * description/metadata. */ +export interface TaskSummary { + id: string; + subject: string; + status: TaskStatus; + owner?: string; + blockedBy: string[]; +} + +/** In-memory task store. The task tools operate on it via `ctx.taskStore`. Mutations emit a + * snapshot through an optional `onChange` callback the loop wires up, so each create/update can + * refresh a UI checklist. Purely in-memory (per-session); not persisted. */ +export class TaskStore { + private tasks = new Map(); + private seq = 0; + private emitter: ((tasks: TaskSummary[]) => void) | undefined; + + /** Wired by the host so the store can broadcast a snapshot after each mutation. */ + setEmitter(emit: (tasks: TaskSummary[]) => void): void { + this.emitter = emit; + } + + private nextId(): string { + this.seq += 1; + return `t${this.seq}`; + } + + private snapshot(): TaskSummary[] { + return [...this.tasks.values()].map((t) => ({ + id: t.id, + subject: t.subject, + status: t.status, + owner: t.owner, + blockedBy: [...t.blockedBy], + })); + } + + private emit(): void { + this.emitter?.(this.snapshot()); + } + + create(input: { + subject: string; + description: string; + activeForm?: string; + metadata?: Record; + }): Task { + const task: Task = { + id: this.nextId(), + subject: input.subject, + description: input.description, + activeForm: input.activeForm, + status: "pending", + blocks: [], + blockedBy: [], + metadata: input.metadata ? { ...input.metadata } : undefined, + }; + this.tasks.set(task.id, task); + this.emit(); + return task; + } + + list(): TaskSummary[] { + return this.snapshot(); + } + + get(id: string): Task | undefined { + return this.tasks.get(id); + } + + /** Applies an update. `status: "deleted"` removes the task (and prunes dangling block/blockedBy + * refs). Returns the updated task, or undefined if the task was deleted or doesn't exist. */ + update( + id: string, + updates: { + status?: TaskStatus | "deleted"; + subject?: string; + description?: string; + activeForm?: string; + owner?: string; + addBlocks?: string[]; + addBlockedBy?: string[]; + metadata?: Record; + }, + ): Task | undefined { + const task = this.tasks.get(id); + if (!task) return undefined; + if (updates.status === "deleted") { + this.remove(id); + return undefined; + } + if (updates.status) task.status = updates.status; + if (updates.subject !== undefined) task.subject = updates.subject; + if (updates.description !== undefined) task.description = updates.description; + if (updates.activeForm !== undefined) task.activeForm = updates.activeForm; + if (updates.owner !== undefined) task.owner = updates.owner; + if (updates.addBlocks) { + for (const b of updates.addBlocks) { + // Only link to existing OTHER tasks; ignore self-refs, unknown ids, and duplicates. + if (b !== id && this.tasks.has(b) && !task.blocks.includes(b)) task.blocks.push(b); + } + } + if (updates.addBlockedBy) { + for (const b of updates.addBlockedBy) { + if (b === id || !this.tasks.has(b) || task.blockedBy.includes(b)) continue; + // Skip a direct 2-cycle: if b is already waiting on this task, don't make them wait on each other. + const other = this.tasks.get(b); + if (other && other.blockedBy.includes(id)) continue; + task.blockedBy.push(b); + } + } + if (updates.metadata) { + task.metadata = mergeMetadata(task.metadata, updates.metadata); + } + this.emit(); + return task; + } + + private remove(id: string): void { + this.tasks.delete(id); + // Prune dangling dependency refs in surviving tasks. + for (const other of this.tasks.values()) { + other.blocks = other.blocks.filter((b) => b !== id); + other.blockedBy = other.blockedBy.filter((b) => b !== id); + } + this.emit(); + } +} + +/** Merge-patches metadata: a null value deletes the key, any other value sets it. */ +function mergeMetadata( + existing: Record | undefined, + patch: Record, +): Record | undefined { + const out: Record = { ...(existing ?? {}) }; + for (const [k, v] of Object.entries(patch)) { + if (v === null) delete out[k]; + else out[k] = v; + } + return Object.keys(out).length > 0 ? out : undefined; +} + +/** Returns a deep-enough copy of a task so handing it back in a tool result can't let the caller + * mutate the store's internal object. */ +function serializeTask(t: Task): Task { + return { + ...t, + blocks: [...t.blocks], + blockedBy: [...t.blockedBy], + metadata: t.metadata ? { ...t.metadata } : undefined, + }; +} + +const metadataSchema = z.record(z.string(), z.any()).optional(); + +const taskCreateSchema = z.object({ + subject: z.string().min(1).describe("A brief, actionable title in imperative form (e.g. 'Fix authentication bug')."), + description: z.string().describe("What needs to be done, in enough detail to act on."), + activeForm: z + .string() + .optional() + .describe("Present-continuous label shown in the spinner while in_progress (e.g. 'Running tests'). Optional."), + metadata: metadataSchema, +}); + +export const taskCreateTool: ToolDef> = { + name: "task_create", + description: + "Create a structured task to track a unit of multi-step work. Use for non-trivial work (3+ steps) so progress is " + + "visible and dependencies can be expressed. Returns the new task with its id. Call task_list to see all tasks, " + + "task_get for full details, and task_update to set status, add dependencies (addBlocks/addBlockedBy), or claim ownership.", + schema: taskCreateSchema, + // Purely informational (tracks state in-memory, never touches the filesystem) — no confirmation prompt. + mutating: false, + handler: async (args, ctx) => { + if (!ctx.taskStore) return { error: "Task tracking is not available in this context." }; + const task = ctx.taskStore.create(args); + return { id: task.id, task: serializeTask(task) }; + }, +}; + +const taskListSchema = z.object({}); + +export const taskListTool: ToolDef> = { + name: "task_list", + description: + "List all tasks with their id, subject, status, owner, and what blocks them. Use this to see overall progress and " + + "find the next available task to claim.", + schema: taskListSchema, + mutating: false, + handler: async (_args, ctx) => { + return { tasks: ctx.taskStore?.list() ?? [] }; + }, +}; + +const taskGetSchema = z.object({ taskId: z.string().min(1) }); + +export const taskGetTool: ToolDef> = { + name: "task_get", + description: + "Get a task's full details (description, activeForm, blocks, blockedBy, metadata). Use before starting a task to " + + "verify its blockedBy list is empty — if it isn't, the blocking tasks must complete first.", + schema: taskGetSchema, + mutating: false, + handler: async (args, ctx) => { + const task = ctx.taskStore?.get(args.taskId); + return task ? { task: serializeTask(task) } : { error: `Task ${args.taskId} not found.` }; + }, +}; + +const taskUpdateSchema = z.object({ + taskId: z.string().min(1), + status: z.enum(["pending", "in_progress", "completed", "deleted"]).optional(), + subject: z.string().optional(), + description: z.string().optional(), + activeForm: z.string().optional(), + owner: z.string().optional(), + addBlocks: z.array(z.string()).optional(), + addBlockedBy: z.array(z.string()).optional(), + metadata: metadataSchema, +}); + +export const taskUpdateTool: ToolDef> = { + name: "task_update", + description: + "Update a task: set status (pending|in_progress|completed — or 'deleted' to remove it), rename subject/description, " + + "set owner to claim it, add dependencies via addBlocks/addBlockedBy (task ids), or merge-patch metadata (set a key " + + "to null to delete it). Mark a task in_progress when starting it and completed when done. Verify blockedBy is empty " + + "before starting. Returns the updated task, or { deleted: id } when status is 'deleted'.", + schema: taskUpdateSchema, + mutating: false, + handler: async (args, ctx) => { + const store = ctx.taskStore; + if (!store) return { error: "Task tracking is not available in this context." }; + if (!store.get(args.taskId)) return { error: `Task ${args.taskId} not found.` }; + if (args.status === "deleted") { + store.update(args.taskId, args); + return { deleted: args.taskId }; + } + const task = store.update(args.taskId, args); + return task ? { task: serializeTask(task) } : { deleted: args.taskId }; + }, +}; \ No newline at end of file diff --git a/src/tools/types.ts b/src/tools/types.ts index 4f29c61..fb58bea 100644 --- a/src/tools/types.ts +++ b/src/tools/types.ts @@ -1,4 +1,5 @@ import type { z } from "zod"; +import type { TaskStore } from "./task.js"; export interface SubAgentTask { /** Short (3-6 word) label shown in the UI while the sub-agent runs. */ @@ -45,6 +46,9 @@ export interface ToolContext { /** Replaces the session's task checklist (used by the `todo_write` tool). Absent only if a * future tool context is built without one — every session-backed context provides it. */ setTodos?: (todos: TodoItem[]) => void; + /** The session's structured task store (used by the task_create/list/get/update tools). Absent + * only if a tool context is built without one — every session-backed context provides it. */ + taskStore?: TaskStore; /** Set only while this specific call is a backgroundable tool (currently just `bash`) — the tool * polls `requested` and, once true, detaches into the background job registry instead of * awaiting completion. Absent for tools that don't support backgrounding. */