feat(task): refactor to Claude Code style individual tools

- Split unified Task tool into individual tools (TaskCreate, TaskGet, TaskList, TaskUpdate)
- Update schema to Claude Code field names (subject, blockedBy, blocks, activeForm, owner, metadata)
- Add OpenCode Todo API sync layer (todo-sync.ts)
- Implement Todo sync on task create/update for continuation enforcement
- Add comprehensive tests for all tools (96 tests total)
- Update AGENTS.md documentation

Breaking Changes:
- Field names changed: title→subject, dependsOn→blockedBy, open→pending
- Tool names changed: task→task_create, task_get, task_list, task_update

Closes: todo-continuation-enforcer now sees Task-created items
This commit is contained in:
YeonGyu-Kim
2026-02-02 12:01:43 +09:00
parent 6288251a67
commit 92639ca38f
16 changed files with 2224 additions and 64 deletions

View File

@@ -22,21 +22,33 @@ type TaskStatus = "pending" | "in_progress" | "completed" | "deleted"
interface Task {
id: string
subject: string // Imperative: "Run tests"
subject: string // Imperative: "Run tests" (was: title)
description: string
status: TaskStatus
activeForm?: string // Present continuous: "Running tests"
blocks: string[] // Task IDs this task blocks
blockedBy: string[] // Task IDs blocking this task
blockedBy: string[] // Task IDs blocking this task (was: dependsOn)
owner?: string // Agent name
metadata?: Record<string, unknown>
repoURL?: string // oh-my-opencode specific
parentID?: string // oh-my-opencode specific
threadID: string // oh-my-opencode specific
}
```
**Key Differences from Legacy**:
- `subject` (was `title`)
- `blockedBy` (was `dependsOn`)
- No `parentID`, `repoURL`, `threadID` fields
- `blocks` (new field)
- `activeForm` (new field)
## TODO SYNC
The task system includes a sync layer (`todo-sync.ts`) that automatically mirrors task state to the project's Todo system.
- **Creation**: Creating a task via `task_create` adds a corresponding item to the Todo list.
- **Updates**: Updating a task's `status` or `subject` via `task_update` reflects in the Todo list.
- **Completion**: Marking a task as `completed` automatically marks the Todo item as done.
## STORAGE UTILITIES

View File

@@ -1,4 +1,4 @@
import type { Plugin } from "@opencode-ai/plugin";
import type { Plugin, ToolDefinition } from "@opencode-ai/plugin";
import {
createTodoContinuationEnforcer,
createContextWindowMonitorHook,
@@ -73,7 +73,10 @@ import {
interactive_bash,
startTmuxCheck,
lspManager,
createTask,
createTaskCreateTool,
createTaskGetTool,
createTaskList,
createTaskUpdateTool,
} from "./tools";
import { BackgroundManager } from "./features/background-agent";
import { SkillMcpManager } from "./features/skill-mcp-manager";
@@ -421,7 +424,12 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
});
const newTaskSystemEnabled = pluginConfig.new_task_system_enabled ?? false;
const taskTool = newTaskSystemEnabled ? createTask(pluginConfig) : null;
const taskToolsRecord: Record<string, ToolDefinition> = newTaskSystemEnabled ? {
task_create: createTaskCreateTool(pluginConfig),
task_get: createTaskGetTool(pluginConfig),
task_list: createTaskList(pluginConfig),
task_update: createTaskUpdateTool(pluginConfig),
} : {};
return {
tool: {
@@ -434,7 +442,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
skill_mcp: skillMcpTool,
slashcommand: slashcommandTool,
interactive_bash,
...(taskTool ? { task: taskTool } : {}),
...taskToolsRecord,
},
"chat.message": async (input, output) => {

View File

@@ -37,11 +37,25 @@ tools/
| LSP | lsp_goto_definition, lsp_find_references, lsp_symbols, lsp_diagnostics, lsp_prepare_rename, lsp_rename | Direct |
| Search | ast_grep_search, ast_grep_replace, grep, glob | Direct |
| Session | session_list, session_read, session_search, session_info | Direct |
| Task | task_create, task_get, task_list, task_update | Factory |
| Agent | delegate_task, call_omo_agent | Factory |
| Background | background_output, background_cancel | Factory |
| System | interactive_bash, look_at | Mixed |
| Skill | skill, skill_mcp, slashcommand | Factory |
## TASK TOOLS
Claude Code compatible task management.
- **task_create**: Creates a new task. Auto-generates ID and syncs to Todo.
- Args: `subject`, `description`, `activeForm`, `blocks`, `blockedBy`, `owner`, `metadata`
- **task_get**: Retrieves a task by ID.
- Args: `id`
- **task_list**: Lists active tasks. Filters out completed/deleted by default.
- Args: `status`, `parentID`
- **task_update**: Updates task fields. Supports additive `addBlocks`/`addBlockedBy`.
- Args: `id`, `subject`, `description`, `status`, `activeForm`, `addBlocks`, `addBlockedBy`, `owner`, `metadata`
## HOW TO ADD
1. Create `src/tools/[name]/` with standard files

View File

@@ -47,6 +47,12 @@ type OpencodeClient = PluginInput["client"]
export { createCallOmoAgent } from "./call-omo-agent"
export { createLookAt } from "./look-at"
export { createDelegateTask } from "./delegate-task"
export {
createTaskCreateTool,
createTaskGetTool,
createTaskList,
createTaskUpdateTool,
} from "./task"
export function createBackgroundTools(manager: BackgroundManager, client: OpencodeClient): Record<string, ToolDefinition> {
const outputManager: BackgroundOutputManager = manager
@@ -73,5 +79,3 @@ export const builtinTools: Record<string, ToolDefinition> = {
session_search,
session_info,
}
export { createTask } from "./task"

View File

@@ -1,5 +1,6 @@
export { createTask } from "./task"
export { createTaskCreateTool } from "./task-create"
export { createTaskGetTool } from "./task-get"
export { createTaskList } from "./task-list"
export { createTaskUpdateTool } from "./task-update"
export { syncTaskToTodo, syncAllTasksToTodos } from "./todo-sync"
export type { TaskObject, TaskStatus, TaskCreateInput, TaskListInput, TaskGetInput, TaskUpdateInput, TaskDeleteInput } from "./types"

View File

@@ -0,0 +1,301 @@
import { describe, test, expect, beforeEach, afterEach } from "bun:test"
import { existsSync, rmSync, mkdirSync } from "fs"
import { join } from "path"
import type { TaskObject } from "./types"
import { createTaskCreateTool } from "./task-create"
const TEST_STORAGE = ".test-task-create-tool"
const TEST_DIR = join(process.cwd(), TEST_STORAGE)
const TEST_CONFIG = {
sisyphus: {
tasks: {
storage_path: TEST_STORAGE,
},
},
}
const TEST_SESSION_ID = "test-session-123"
const TEST_ABORT_CONTROLLER = new AbortController()
const TEST_CONTEXT = {
sessionID: TEST_SESSION_ID,
messageID: "test-message-123",
agent: "test-agent",
abort: TEST_ABORT_CONTROLLER.signal,
}
describe("task_create tool", () => {
let tool: ReturnType<typeof createTaskCreateTool>
beforeEach(() => {
if (existsSync(TEST_STORAGE)) {
rmSync(TEST_STORAGE, { recursive: true, force: true })
}
mkdirSync(TEST_DIR, { recursive: true })
tool = createTaskCreateTool(TEST_CONFIG)
})
afterEach(() => {
if (existsSync(TEST_STORAGE)) {
rmSync(TEST_STORAGE, { recursive: true, force: true })
}
})
describe("create action", () => {
test("creates task with required subject field", async () => {
//#given
const args = {
subject: "Implement authentication",
}
//#when
const resultStr = await tool.execute(args, TEST_CONTEXT)
const result = JSON.parse(resultStr)
//#then
expect(result).toHaveProperty("task")
expect(result.task).toHaveProperty("id")
expect(result.task.subject).toBe("Implement authentication")
})
test("auto-generates T-{uuid} format ID", async () => {
//#given
const args = {
subject: "Test task",
}
//#when
const resultStr = await tool.execute(args, TEST_CONTEXT)
const result = JSON.parse(resultStr)
//#then
expect(result.task.id).toMatch(/^T-[a-f0-9-]+$/)
})
test("auto-records threadID from session context", async () => {
//#given
const args = {
subject: "Test task",
}
//#when
const resultStr = await tool.execute(args, TEST_CONTEXT)
const result = JSON.parse(resultStr)
const taskId = result.task.id
//#then
const taskFile = join(TEST_DIR, `${taskId}.json`)
expect(existsSync(taskFile)).toBe(true)
const taskContent = JSON.parse(await Bun.file(taskFile).text())
expect(taskContent.threadID).toBe(TEST_SESSION_ID)
})
test("sets default status to pending", async () => {
//#given
const args = {
subject: "Test task",
}
//#when
const resultStr = await tool.execute(args, TEST_CONTEXT)
const result = JSON.parse(resultStr)
const taskId = result.task.id
//#then
const taskFile = join(TEST_DIR, `${taskId}.json`)
const taskContent = JSON.parse(await Bun.file(taskFile).text())
expect(taskContent.status).toBe("pending")
})
test("sets default blocks and blockedBy to empty arrays", async () => {
//#given
const args = {
subject: "Test task",
}
//#when
const resultStr = await tool.execute(args, TEST_CONTEXT)
const result = JSON.parse(resultStr)
const taskId = result.task.id
//#then
const taskFile = join(TEST_DIR, `${taskId}.json`)
const taskContent = JSON.parse(await Bun.file(taskFile).text())
expect(taskContent.blocks).toEqual([])
expect(taskContent.blockedBy).toEqual([])
})
test("accepts optional description", async () => {
//#given
const args = {
subject: "Test task",
description: "This is a test description",
}
//#when
const resultStr = await tool.execute(args, TEST_CONTEXT)
const result = JSON.parse(resultStr)
const taskId = result.task.id
//#then
const taskFile = join(TEST_DIR, `${taskId}.json`)
const taskContent = JSON.parse(await Bun.file(taskFile).text())
expect(taskContent.description).toBe("This is a test description")
})
test("accepts optional activeForm", async () => {
//#given
const args = {
subject: "Test task",
activeForm: "Implementing authentication",
}
//#when
const resultStr = await tool.execute(args, TEST_CONTEXT)
const result = JSON.parse(resultStr)
const taskId = result.task.id
//#then
const taskFile = join(TEST_DIR, `${taskId}.json`)
const taskContent = JSON.parse(await Bun.file(taskFile).text())
expect(taskContent.activeForm).toBe("Implementing authentication")
})
test("accepts optional metadata", async () => {
//#given
const args = {
subject: "Test task",
metadata: { priority: "high", tags: ["urgent"] },
}
//#when
const resultStr = await tool.execute(args, TEST_CONTEXT)
const result = JSON.parse(resultStr)
const taskId = result.task.id
//#then
const taskFile = join(TEST_DIR, `${taskId}.json`)
const taskContent = JSON.parse(await Bun.file(taskFile).text())
expect(taskContent.metadata).toEqual({ priority: "high", tags: ["urgent"] })
})
test("accepts optional blockedBy array", async () => {
//#given
const args = {
subject: "Test task",
blockedBy: ["T-123", "T-456"],
}
//#when
const resultStr = await tool.execute(args, TEST_CONTEXT)
const result = JSON.parse(resultStr)
const taskId = result.task.id
//#then
const taskFile = join(TEST_DIR, `${taskId}.json`)
const taskContent = JSON.parse(await Bun.file(taskFile).text())
expect(taskContent.blockedBy).toEqual(["T-123", "T-456"])
})
test("accepts optional blocks array", async () => {
//#given
const args = {
subject: "Test task",
blocks: ["T-789", "T-101"],
}
//#when
const resultStr = await tool.execute(args, TEST_CONTEXT)
const result = JSON.parse(resultStr)
const taskId = result.task.id
//#then
const taskFile = join(TEST_DIR, `${taskId}.json`)
const taskContent = JSON.parse(await Bun.file(taskFile).text())
expect(taskContent.blocks).toEqual(["T-789", "T-101"])
})
test("accepts optional repoURL", async () => {
//#given
const args = {
subject: "Test task",
repoURL: "https://github.com/example/repo",
}
//#when
const resultStr = await tool.execute(args, TEST_CONTEXT)
const result = JSON.parse(resultStr)
const taskId = result.task.id
//#then
const taskFile = join(TEST_DIR, `${taskId}.json`)
const taskContent = JSON.parse(await Bun.file(taskFile).text())
expect(taskContent.repoURL).toBe("https://github.com/example/repo")
})
test("accepts optional parentID", async () => {
//#given
const args = {
subject: "Test task",
parentID: "T-parent-123",
}
//#when
const resultStr = await tool.execute(args, TEST_CONTEXT)
const result = JSON.parse(resultStr)
const taskId = result.task.id
//#then
const taskFile = join(TEST_DIR, `${taskId}.json`)
const taskContent = JSON.parse(await Bun.file(taskFile).text())
expect(taskContent.parentID).toBe("T-parent-123")
})
test("returns minimal response with id and subject", async () => {
//#given
const args = {
subject: "Test task",
}
//#when
const resultStr = await tool.execute(args, TEST_CONTEXT)
const result = JSON.parse(resultStr)
//#then
expect(result.task).toHaveProperty("id")
expect(result.task).toHaveProperty("subject")
expect(result.task.subject).toBe("Test task")
})
test("rejects missing subject", async () => {
//#given
const args = {}
//#when
const resultStr = await tool.execute(args, TEST_CONTEXT)
const result = JSON.parse(resultStr)
//#then
expect(result).toHaveProperty("error")
})
test("writes task to file storage atomically", async () => {
//#given
const args = {
subject: "Test task",
description: "Test description",
}
//#when
const resultStr = await tool.execute(args, TEST_CONTEXT)
const result = JSON.parse(resultStr)
const taskId = result.task.id
//#then
const taskFile = join(TEST_DIR, `${taskId}.json`)
expect(existsSync(taskFile)).toBe(true)
const taskContent = JSON.parse(await Bun.file(taskFile).text())
expect(taskContent.id).toBe(taskId)
expect(taskContent.subject).toBe("Test task")
expect(taskContent.description).toBe("Test description")
})
})
})

View File

@@ -0,0 +1,97 @@
import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool"
import { join } from "path"
import type { OhMyOpenCodeConfig } from "../../config/schema"
import type { TaskObject } from "./types"
import { TaskObjectSchema, TaskCreateInputSchema } from "./types"
import {
getTaskDir,
writeJsonAtomic,
acquireLock,
generateTaskId,
} from "../../features/claude-tasks/storage"
import { syncTaskToTodo } from "./todo-sync"
export function createTaskCreateTool(
config: Partial<OhMyOpenCodeConfig>
): ToolDefinition {
return tool({
description: `Create a new task with auto-generated ID and threadID recording.
Auto-generates T-{uuid} ID, records threadID from context, sets status to "pending".
Returns minimal response with task ID and subject.`,
args: {
subject: tool.schema.string().describe("Task subject (required)"),
description: tool.schema.string().optional().describe("Task description"),
activeForm: tool.schema.string().optional().describe("Active form (present continuous)"),
metadata: tool.schema
.record(tool.schema.string(), tool.schema.unknown())
.optional()
.describe("Task metadata"),
blockedBy: tool.schema
.array(tool.schema.string())
.optional()
.describe("Task IDs blocking this task"),
blocks: tool.schema
.array(tool.schema.string())
.optional()
.describe("Task IDs this task blocks"),
repoURL: tool.schema.string().optional().describe("Repository URL"),
parentID: tool.schema.string().optional().describe("Parent task ID"),
},
execute: async (args, context) => {
return handleCreate(args, config, context)
},
})
}
async function handleCreate(
args: Record<string, unknown>,
config: Partial<OhMyOpenCodeConfig>,
context: { sessionID: string }
): Promise<string> {
try {
const validatedArgs = TaskCreateInputSchema.parse(args)
const taskDir = getTaskDir(config)
const lock = acquireLock(taskDir)
if (!lock.acquired) {
return JSON.stringify({ error: "task_lock_unavailable" })
}
try {
const taskId = generateTaskId()
const task: TaskObject = {
id: taskId,
subject: validatedArgs.subject,
description: validatedArgs.description ?? "",
status: "pending",
blocks: validatedArgs.blocks ?? [],
blockedBy: validatedArgs.blockedBy ?? [],
activeForm: validatedArgs.activeForm,
metadata: validatedArgs.metadata,
repoURL: validatedArgs.repoURL,
parentID: validatedArgs.parentID,
threadID: context.sessionID,
}
const validatedTask = TaskObjectSchema.parse(task)
writeJsonAtomic(join(taskDir, `${taskId}.json`), validatedTask)
syncTaskToTodo(validatedTask)
return JSON.stringify({
task: {
id: validatedTask.id,
subject: validatedTask.subject,
},
})
} finally {
lock.release()
}
} catch (error) {
if (error instanceof Error && error.message.includes("Required")) {
return JSON.stringify({ error: "validation_error", message: error.message })
}
return JSON.stringify({ error: "internal_error" })
}
}

View File

@@ -0,0 +1,222 @@
import { describe, test, expect, beforeEach, afterEach } from "bun:test"
import { existsSync, rmSync, mkdirSync, writeFileSync } from "fs"
import { join } from "path"
import type { TaskObject } from "./types"
import { createTaskGetTool } from "./task-get"
const TEST_STORAGE = ".test-task-get-tool"
const TEST_DIR = join(process.cwd(), TEST_STORAGE)
const TEST_CONFIG = {
sisyphus: {
tasks: {
storage_path: TEST_STORAGE,
},
},
}
const TEST_SESSION_ID = "test-session-123"
const TEST_ABORT_CONTROLLER = new AbortController()
const TEST_CONTEXT = {
sessionID: TEST_SESSION_ID,
messageID: "test-message-123",
agent: "test-agent",
abort: TEST_ABORT_CONTROLLER.signal,
}
describe("task_get tool", () => {
let tool: ReturnType<typeof createTaskGetTool>
beforeEach(() => {
if (existsSync(TEST_STORAGE)) {
rmSync(TEST_STORAGE, { recursive: true, force: true })
}
mkdirSync(TEST_DIR, { recursive: true })
tool = createTaskGetTool(TEST_CONFIG)
})
afterEach(() => {
if (existsSync(TEST_STORAGE)) {
rmSync(TEST_STORAGE, { recursive: true, force: true })
}
})
describe("get action", () => {
test("retrieves existing task by ID", async () => {
//#given
const taskId = "T-test-123"
const taskData: TaskObject = {
id: taskId,
subject: "Test task",
description: "Test description",
status: "pending",
blocks: [],
blockedBy: [],
threadID: TEST_SESSION_ID,
}
const taskFile = join(TEST_DIR, `${taskId}.json`)
writeFileSync(taskFile, JSON.stringify(taskData, null, 2))
//#when
const resultStr = await tool.execute({ id: taskId }, TEST_CONTEXT)
const result = JSON.parse(resultStr)
//#then
expect(result).toHaveProperty("task")
expect(result.task).not.toBeNull()
expect(result.task.id).toBe(taskId)
expect(result.task.subject).toBe("Test task")
expect(result.task.description).toBe("Test description")
})
test("returns null for non-existent task", async () => {
//#given
const taskId = "T-nonexistent-999"
//#when
const resultStr = await tool.execute({ id: taskId }, TEST_CONTEXT)
const result = JSON.parse(resultStr)
//#then
expect(result).toHaveProperty("task")
expect(result.task).toBeNull()
})
test("returns full task object with all fields", async () => {
//#given
const taskId = "T-full-task-456"
const taskData: TaskObject = {
id: taskId,
subject: "Complex task",
description: "Full description",
status: "in_progress",
activeForm: "Working on complex task",
blocks: ["T-blocked-1", "T-blocked-2"],
blockedBy: ["T-blocker-1"],
owner: "test-agent",
metadata: { priority: "high", tags: ["urgent", "backend"] },
repoURL: "https://github.com/example/repo",
parentID: "T-parent-123",
threadID: TEST_SESSION_ID,
}
const taskFile = join(TEST_DIR, `${taskId}.json`)
writeFileSync(taskFile, JSON.stringify(taskData, null, 2))
//#when
const resultStr = await tool.execute({ id: taskId }, TEST_CONTEXT)
const result = JSON.parse(resultStr)
//#then
expect(result.task).toEqual(taskData)
expect(result.task.blocks).toEqual(["T-blocked-1", "T-blocked-2"])
expect(result.task.blockedBy).toEqual(["T-blocker-1"])
expect(result.task.metadata).toEqual({ priority: "high", tags: ["urgent", "backend"] })
})
test("rejects invalid task ID format", async () => {
//#given
const invalidTaskId = "invalid-id-format"
//#when
const resultStr = await tool.execute({ id: invalidTaskId }, TEST_CONTEXT)
const result = JSON.parse(resultStr)
//#then
expect(result).toHaveProperty("error")
expect(result.error).toBe("invalid_task_id")
})
test("returns null for malformed task file", async () => {
//#given
const taskId = "T-malformed-789"
const taskFile = join(TEST_DIR, `${taskId}.json`)
writeFileSync(taskFile, "{ invalid json }")
//#when
const resultStr = await tool.execute({ id: taskId }, TEST_CONTEXT)
const result = JSON.parse(resultStr)
//#then
expect(result.task).toBeNull()
})
test("returns null for task file with invalid schema", async () => {
//#given
const taskId = "T-invalid-schema-101"
const taskFile = join(TEST_DIR, `${taskId}.json`)
const invalidData = {
id: taskId,
subject: "Missing required fields",
// Missing description and threadID
}
writeFileSync(taskFile, JSON.stringify(invalidData, null, 2))
//#when
const resultStr = await tool.execute({ id: taskId }, TEST_CONTEXT)
const result = JSON.parse(resultStr)
//#then
expect(result.task).toBeNull()
})
test("requires id parameter", async () => {
//#given
const args = {}
//#when
const resultStr = await tool.execute(args, TEST_CONTEXT)
const result = JSON.parse(resultStr)
//#then
expect(result).toHaveProperty("error")
})
test("handles task with empty blocks and blockedBy arrays", async () => {
//#given
const taskId = "T-empty-arrays-202"
const taskData: TaskObject = {
id: taskId,
subject: "Task with empty arrays",
description: "Test",
status: "pending",
blocks: [],
blockedBy: [],
threadID: TEST_SESSION_ID,
}
const taskFile = join(TEST_DIR, `${taskId}.json`)
writeFileSync(taskFile, JSON.stringify(taskData, null, 2))
//#when
const resultStr = await tool.execute({ id: taskId }, TEST_CONTEXT)
const result = JSON.parse(resultStr)
//#then
expect(result.task.blocks).toEqual([])
expect(result.task.blockedBy).toEqual([])
})
test("handles task with optional fields omitted", async () => {
//#given
const taskId = "T-minimal-303"
const taskData: TaskObject = {
id: taskId,
subject: "Minimal task",
description: "Minimal",
status: "pending",
blocks: [],
blockedBy: [],
threadID: TEST_SESSION_ID,
}
const taskFile = join(TEST_DIR, `${taskId}.json`)
writeFileSync(taskFile, JSON.stringify(taskData, null, 2))
//#when
const resultStr = await tool.execute({ id: taskId }, TEST_CONTEXT)
const result = JSON.parse(resultStr)
//#then
expect(result.task).not.toBeNull()
expect(result.task.id).toBe(taskId)
expect(result.task.owner).toBeUndefined()
expect(result.task.metadata).toBeUndefined()
})
})
})

View File

@@ -0,0 +1,48 @@
import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool"
import { join } from "path"
import type { OhMyOpenCodeConfig } from "../../config/schema"
import type { TaskGetInput } from "./types"
import { TaskGetInputSchema, TaskObjectSchema } from "./types"
import { getTaskDir, readJsonSafe } from "../../features/claude-tasks/storage"
const TASK_ID_PATTERN = /^T-[A-Za-z0-9-]+$/
function parseTaskId(id: string): string | null {
if (!TASK_ID_PATTERN.test(id)) return null
return id
}
export function createTaskGetTool(config: Partial<OhMyOpenCodeConfig>): ToolDefinition {
return tool({
description: `Retrieve a task by ID.
Returns the full task object including all fields: id, subject, description, status, activeForm, blocks, blockedBy, owner, metadata, repoURL, parentID, and threadID.
Returns null if the task does not exist or the file is invalid.`,
args: {
id: tool.schema.string().describe("Task ID to retrieve (format: T-{uuid})"),
},
execute: async (args: Record<string, unknown>): Promise<string> => {
try {
const validatedArgs = TaskGetInputSchema.parse(args)
const taskId = parseTaskId(validatedArgs.id)
if (!taskId) {
return JSON.stringify({ error: "invalid_task_id" })
}
const taskDir = getTaskDir(config)
const taskPath = join(taskDir, `${taskId}.json`)
const task = readJsonSafe(taskPath, TaskObjectSchema)
return JSON.stringify({ task: task ?? null })
} catch (error) {
if (error instanceof Error && error.message.includes("validation")) {
return JSON.stringify({ error: "invalid_arguments" })
}
return JSON.stringify({ error: "unknown_error" })
}
},
})
}

View File

@@ -0,0 +1,335 @@
import { describe, it, expect, beforeEach, afterEach } from "bun:test"
import { createTaskList } from "./task-list"
import { writeJsonAtomic } from "../../features/claude-tasks/storage"
import type { TaskObject } from "./types"
import { join } from "path"
import { existsSync, rmSync } from "fs"
const testProjectDir = "/tmp/task-list-test"
describe("createTaskList", () => {
let taskDir: string
beforeEach(() => {
taskDir = join(testProjectDir, ".sisyphus/tasks")
if (existsSync(taskDir)) {
rmSync(taskDir, { recursive: true })
}
})
afterEach(() => {
if (existsSync(taskDir)) {
rmSync(taskDir, { recursive: true })
}
})
it("returns empty array when no tasks exist", async () => {
//#given
const config = {
sisyphus: {
tasks: {
storage_path: join(testProjectDir, ".sisyphus/tasks"),
claude_code_compat: false,
},
},
}
const tool = createTaskList(config)
//#when
const result = await tool.execute({}, { sessionID: "test-session" })
//#then
const parsed = JSON.parse(result)
expect(parsed.tasks).toEqual([])
})
it("excludes completed tasks by default", async () => {
//#given
const task1: TaskObject = {
id: "T-1",
subject: "Active task",
description: "Should be included",
status: "pending",
blocks: [],
blockedBy: [],
threadID: "test-session",
}
const task2: TaskObject = {
id: "T-2",
subject: "Completed task",
description: "Should be excluded",
status: "completed",
blocks: [],
blockedBy: [],
threadID: "test-session",
}
writeJsonAtomic(join(testProjectDir, ".sisyphus/tasks", "T-1.json"), task1)
writeJsonAtomic(join(testProjectDir, ".sisyphus/tasks", "T-2.json"), task2)
const config = {
sisyphus: {
tasks: {
storage_path: join(testProjectDir, ".sisyphus/tasks"),
claude_code_compat: false,
},
},
}
const tool = createTaskList(config)
//#when
const result = await tool.execute({}, { sessionID: "test-session" })
//#then
const parsed = JSON.parse(result)
expect(parsed.tasks).toHaveLength(1)
expect(parsed.tasks[0].id).toBe("T-1")
})
it("excludes deleted tasks by default", async () => {
//#given
const task1: TaskObject = {
id: "T-1",
subject: "Active task",
description: "Should be included",
status: "pending",
blocks: [],
blockedBy: [],
threadID: "test-session",
}
const task2: TaskObject = {
id: "T-2",
subject: "Deleted task",
description: "Should be excluded",
status: "deleted",
blocks: [],
blockedBy: [],
threadID: "test-session",
}
writeJsonAtomic(join(testProjectDir, ".sisyphus/tasks", "T-1.json"), task1)
writeJsonAtomic(join(testProjectDir, ".sisyphus/tasks", "T-2.json"), task2)
const config = {
sisyphus: {
tasks: {
storage_path: join(testProjectDir, ".sisyphus/tasks"),
claude_code_compat: false,
},
},
}
const tool = createTaskList(config)
//#when
const result = await tool.execute({}, { sessionID: "test-session" })
//#then
const parsed = JSON.parse(result)
expect(parsed.tasks).toHaveLength(1)
expect(parsed.tasks[0].id).toBe("T-1")
})
it("returns summary format with id, subject, status, owner, blockedBy", async () => {
//#given
const task: TaskObject = {
id: "T-1",
subject: "Test task",
description: "This is a long description that should not be included",
status: "in_progress",
owner: "sisyphus",
blocks: [],
blockedBy: ["T-2"],
threadID: "test-session",
}
writeJsonAtomic(join(testProjectDir, ".sisyphus/tasks", "T-1.json"), task)
const config = {
sisyphus: {
tasks: {
storage_path: join(testProjectDir, ".sisyphus/tasks"),
claude_code_compat: false,
},
},
}
const tool = createTaskList(config)
//#when
const result = await tool.execute({}, { sessionID: "test-session" })
//#then
const parsed = JSON.parse(result)
expect(parsed.tasks).toHaveLength(1)
const summary = parsed.tasks[0]
expect(summary).toHaveProperty("id")
expect(summary).toHaveProperty("subject")
expect(summary).toHaveProperty("status")
expect(summary).toHaveProperty("owner")
expect(summary).toHaveProperty("blockedBy")
expect(summary).not.toHaveProperty("description")
expect(summary.id).toBe("T-1")
expect(summary.subject).toBe("Test task")
expect(summary.status).toBe("in_progress")
expect(summary.owner).toBe("sisyphus")
expect(summary.blockedBy).toEqual(["T-2"])
})
it("filters blockedBy to only include unresolved (non-completed) blockers", async () => {
//#given
const blockerCompleted: TaskObject = {
id: "T-blocker-completed",
subject: "Completed blocker",
description: "",
status: "completed",
blocks: [],
blockedBy: [],
threadID: "test-session",
}
const blockerPending: TaskObject = {
id: "T-blocker-pending",
subject: "Pending blocker",
description: "",
status: "pending",
blocks: [],
blockedBy: [],
threadID: "test-session",
}
const mainTask: TaskObject = {
id: "T-main",
subject: "Main task",
description: "",
status: "pending",
blocks: [],
blockedBy: ["T-blocker-completed", "T-blocker-pending"],
threadID: "test-session",
}
writeJsonAtomic(join(testProjectDir, ".sisyphus/tasks", "T-blocker-completed.json"), blockerCompleted)
writeJsonAtomic(join(testProjectDir, ".sisyphus/tasks", "T-blocker-pending.json"), blockerPending)
writeJsonAtomic(join(testProjectDir, ".sisyphus/tasks", "T-main.json"), mainTask)
const config = {
sisyphus: {
tasks: {
storage_path: join(testProjectDir, ".sisyphus/tasks"),
claude_code_compat: false,
},
},
}
const tool = createTaskList(config)
//#when
const result = await tool.execute({}, { sessionID: "test-session" })
//#then
const parsed = JSON.parse(result)
const mainTaskSummary = parsed.tasks.find((t: { id: string }) => t.id === "T-main")
expect(mainTaskSummary.blockedBy).toEqual(["T-blocker-pending"])
})
it("includes all active statuses (pending, in_progress)", async () => {
//#given
const task1: TaskObject = {
id: "T-1",
subject: "Pending task",
description: "",
status: "pending",
blocks: [],
blockedBy: [],
threadID: "test-session",
}
const task2: TaskObject = {
id: "T-2",
subject: "In progress task",
description: "",
status: "in_progress",
blocks: [],
blockedBy: [],
threadID: "test-session",
}
writeJsonAtomic(join(testProjectDir, ".sisyphus/tasks", "T-1.json"), task1)
writeJsonAtomic(join(testProjectDir, ".sisyphus/tasks", "T-2.json"), task2)
const config = {
sisyphus: {
tasks: {
storage_path: join(testProjectDir, ".sisyphus/tasks"),
claude_code_compat: false,
},
},
}
const tool = createTaskList(config)
//#when
const result = await tool.execute({}, { sessionID: "test-session" })
//#then
const parsed = JSON.parse(result)
expect(parsed.tasks).toHaveLength(2)
})
it("handles tasks with no blockedBy gracefully", async () => {
//#given
const task: TaskObject = {
id: "T-1",
subject: "Task with no blockers",
description: "",
status: "pending",
blocks: [],
blockedBy: [],
threadID: "test-session",
}
writeJsonAtomic(join(testProjectDir, ".sisyphus/tasks", "T-1.json"), task)
const config = {
sisyphus: {
tasks: {
storage_path: join(testProjectDir, ".sisyphus/tasks"),
claude_code_compat: false,
},
},
}
const tool = createTaskList(config)
//#when
const result = await tool.execute({}, { sessionID: "test-session" })
//#then
const parsed = JSON.parse(result)
expect(parsed.tasks[0].blockedBy).toEqual([])
})
it("handles missing blocker tasks gracefully", async () => {
//#given
const task: TaskObject = {
id: "T-1",
subject: "Task with missing blocker",
description: "",
status: "pending",
blocks: [],
blockedBy: ["T-missing"],
threadID: "test-session",
}
writeJsonAtomic(join(testProjectDir, ".sisyphus/tasks", "T-1.json"), task)
const config = {
sisyphus: {
tasks: {
storage_path: join(testProjectDir, ".sisyphus/tasks"),
claude_code_compat: false,
},
},
}
const tool = createTaskList(config)
//#when
const result = await tool.execute({}, { sessionID: "test-session" })
//#then
const parsed = JSON.parse(result)
expect(parsed.tasks[0].blockedBy).toEqual(["T-missing"])
})
})

View File

@@ -0,0 +1,76 @@
import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool"
import { join } from "path"
import { existsSync, readdirSync } from "fs"
import type { OhMyOpenCodeConfig } from "../../config/schema"
import type { TaskObject, TaskStatus } from "./types"
import { TaskObjectSchema } from "./types"
import { readJsonSafe } from "../../features/claude-tasks/storage"
interface TaskSummary {
id: string
subject: string
status: TaskStatus
owner?: string
blockedBy: string[]
}
export function createTaskList(config: Partial<OhMyOpenCodeConfig>): ToolDefinition {
return tool({
description: `List all active tasks with summary information.
Returns tasks excluding completed and deleted statuses by default.
For each task's blockedBy field, filters to only include unresolved (non-completed) blockers.
Returns summary format: id, subject, status, owner, blockedBy (not full description).`,
args: {},
execute: async (): Promise<string> => {
const tasksConfig = config.sisyphus?.tasks
const storagePath = tasksConfig?.storage_path ?? ".sisyphus/tasks"
const taskDir = storagePath.startsWith("/") ? storagePath : join(process.cwd(), storagePath)
if (!existsSync(taskDir)) {
return JSON.stringify({ tasks: [] })
}
const files = readdirSync(taskDir)
.filter((f) => f.endsWith(".json") && f.startsWith("T-"))
.map((f) => f.replace(".json", ""))
if (files.length === 0) {
return JSON.stringify({ tasks: [] })
}
const allTasks: TaskObject[] = []
for (const fileId of files) {
const task = readJsonSafe(join(taskDir, `${fileId}.json`), TaskObjectSchema)
if (task) {
allTasks.push(task)
}
}
// Filter out completed and deleted tasks
const activeTasks = allTasks.filter(
(task) => task.status !== "completed" && task.status !== "deleted"
)
// Build summary with filtered blockedBy
const summaries: TaskSummary[] = activeTasks.map((task) => {
// Filter blockedBy to only include unresolved (non-completed) blockers
const unresolvedBlockers = task.blockedBy.filter((blockerId) => {
const blockerTask = allTasks.find((t) => t.id === blockerId)
// Include if blocker doesn't exist (missing) or if it's not completed
return !blockerTask || blockerTask.status !== "completed"
})
return {
id: task.id,
subject: task.subject,
status: task.status,
owner: task.owner,
blockedBy: unresolvedBlockers,
}
})
return JSON.stringify({ tasks: summaries })
},
})
}

View File

@@ -38,27 +38,27 @@ export function createTask(config: Partial<OhMyOpenCodeConfig>): ToolDefinition
return tool({
description: `Unified task management tool with create, list, get, update, delete actions.
**CREATE**: Create a new task. Auto-generates T-{uuid} ID, records threadID, sets status to "open".
**LIST**: List tasks. Excludes completed by default. Supports ready filter (all dependencies completed) and limit.
**GET**: Retrieve a task by ID.
**UPDATE**: Update task fields. Requires task ID.
**DELETE**: Physically remove task file.
CREATE: Create a new task. Auto-generates T-{uuid} ID, records threadID, sets status to "pending".
LIST: List tasks. Excludes completed by default. Supports ready filter (all dependencies completed) and limit.
GET: Retrieve a task by ID.
UPDATE: Update task fields. Requires task ID.
DELETE: Physically remove task file.
All actions return JSON strings.`,
args: {
action: tool.schema
.enum(["create", "list", "get", "update", "delete"])
.describe("Action to perform: create, list, get, update, delete"),
title: tool.schema.string().optional().describe("Task title (required for create)"),
subject: tool.schema.string().optional().describe("Task subject (required for create)"),
description: tool.schema.string().optional().describe("Task description"),
status: tool.schema
.enum(["open", "in_progress", "completed"])
.enum(["pending", "in_progress", "completed", "deleted"])
.optional()
.describe("Task status"),
dependsOn: tool.schema
blockedBy: tool.schema
.array(tool.schema.string())
.optional()
.describe("Task IDs this task depends on"),
.describe("Task IDs this task is blocked by"),
repoURL: tool.schema.string().optional().describe("Repository URL"),
parentID: tool.schema.string().optional().describe("Parent task ID"),
id: tool.schema.string().optional().describe("Task ID (required for get, update, delete)"),
@@ -99,18 +99,19 @@ async function handleCreate(
return JSON.stringify({ error: "task_lock_unavailable" })
}
try {
const taskId = generateTaskId()
const task: TaskObject = {
id: taskId,
title: validatedArgs.title,
description: validatedArgs.description,
status: "open",
dependsOn: validatedArgs.dependsOn ?? [],
repoURL: validatedArgs.repoURL,
parentID: validatedArgs.parentID,
threadID: context.sessionID,
}
try {
const taskId = generateTaskId()
const task: TaskObject = {
id: taskId,
subject: validatedArgs.subject,
description: validatedArgs.description ?? "",
status: "pending",
blocks: validatedArgs.blocks ?? [],
blockedBy: validatedArgs.blockedBy ?? [],
repoURL: validatedArgs.repoURL,
parentID: validatedArgs.parentID,
threadID: context.sessionID,
}
const validatedTask = TaskObjectSchema.parse(task)
writeJsonAtomic(join(taskDir, `${taskId}.json`), validatedTask)
@@ -158,20 +159,20 @@ async function handleList(
tasks = tasks.filter((task) => task.parentID === validatedArgs.parentID)
}
// Apply ready filter if requested
if (args.ready) {
tasks = tasks.filter((task) => {
if (task.dependsOn.length === 0) {
return true
}
// Apply ready filter if requested
if (args.ready) {
tasks = tasks.filter((task) => {
if (task.blockedBy.length === 0) {
return true
}
// All dependencies must be completed
return task.dependsOn.every((depId) => {
const depTask = allTasks.find((t) => t.id === depId)
return depTask?.status === "completed"
})
})
}
// All blocking tasks must be completed
return task.blockedBy.every((depId: string) => {
const depTask = allTasks.find((t) => t.id === depId)
return depTask?.status === "completed"
})
})
}
// Apply limit if provided
const limit = args.limit as number | undefined
@@ -223,25 +224,25 @@ async function handleUpdate(
return JSON.stringify({ error: "task_not_found" })
}
// Update fields if provided
if (validatedArgs.title !== undefined) {
task.title = validatedArgs.title
}
if (validatedArgs.description !== undefined) {
task.description = validatedArgs.description
}
if (validatedArgs.status !== undefined) {
task.status = validatedArgs.status
}
if (validatedArgs.dependsOn !== undefined) {
task.dependsOn = validatedArgs.dependsOn
}
if (validatedArgs.repoURL !== undefined) {
task.repoURL = validatedArgs.repoURL
}
if (validatedArgs.parentID !== undefined) {
task.parentID = validatedArgs.parentID
}
// Update fields if provided
if (validatedArgs.subject !== undefined) {
task.subject = validatedArgs.subject
}
if (validatedArgs.description !== undefined) {
task.description = validatedArgs.description
}
if (validatedArgs.status !== undefined) {
task.status = validatedArgs.status
}
if (validatedArgs.addBlockedBy !== undefined) {
task.blockedBy = [...task.blockedBy, ...validatedArgs.addBlockedBy]
}
if (validatedArgs.repoURL !== undefined) {
task.repoURL = validatedArgs.repoURL
}
if (validatedArgs.parentID !== undefined) {
task.parentID = validatedArgs.parentID
}
const validatedTask = TaskObjectSchema.parse(task)
writeJsonAtomic(taskPath, validatedTask)

View File

@@ -0,0 +1,408 @@
import { describe, it, expect, beforeEach, vi } from "bun:test"
import type { Task } from "../../features/claude-tasks/types"
import { syncTaskToTodo, syncAllTasksToTodos, type TodoInfo } from "./todo-sync"
describe("syncTaskToTodo", () => {
it("converts pending task to pending todo", () => {
// given
const task: Task = {
id: "T-123",
subject: "Fix bug",
description: "Fix critical bug",
status: "pending",
blocks: [],
blockedBy: [],
}
// when
const result = syncTaskToTodo(task)
// then
expect(result).toEqual({
id: "T-123",
content: "Fix bug",
status: "pending",
priority: undefined,
})
})
it("converts in_progress task to in_progress todo", () => {
// given
const task: Task = {
id: "T-456",
subject: "Implement feature",
description: "Add new feature",
status: "in_progress",
blocks: [],
blockedBy: [],
}
// when
const result = syncTaskToTodo(task)
// then
expect(result?.status).toBe("in_progress")
expect(result?.content).toBe("Implement feature")
})
it("converts completed task to completed todo", () => {
// given
const task: Task = {
id: "T-789",
subject: "Review PR",
description: "Review pull request",
status: "completed",
blocks: [],
blockedBy: [],
}
// when
const result = syncTaskToTodo(task)
// then
expect(result?.status).toBe("completed")
})
it("returns null for deleted task", () => {
// given
const task: Task = {
id: "T-del",
subject: "Deleted task",
description: "This task is deleted",
status: "deleted",
blocks: [],
blockedBy: [],
}
// when
const result = syncTaskToTodo(task)
// then
expect(result).toBeNull()
})
it("extracts priority from metadata", () => {
// given
const task: Task = {
id: "T-high",
subject: "Critical task",
description: "High priority task",
status: "pending",
blocks: [],
blockedBy: [],
metadata: { priority: "high" },
}
// when
const result = syncTaskToTodo(task)
// then
expect(result?.priority).toBe("high")
})
it("handles medium priority", () => {
// given
const task: Task = {
id: "T-med",
subject: "Medium task",
description: "Medium priority",
status: "pending",
blocks: [],
blockedBy: [],
metadata: { priority: "medium" },
}
// when
const result = syncTaskToTodo(task)
// then
expect(result?.priority).toBe("medium")
})
it("handles low priority", () => {
// given
const task: Task = {
id: "T-low",
subject: "Low task",
description: "Low priority",
status: "pending",
blocks: [],
blockedBy: [],
metadata: { priority: "low" },
}
// when
const result = syncTaskToTodo(task)
// then
expect(result?.priority).toBe("low")
})
it("ignores invalid priority values", () => {
// given
const task: Task = {
id: "T-invalid",
subject: "Invalid priority",
description: "Invalid priority value",
status: "pending",
blocks: [],
blockedBy: [],
metadata: { priority: "urgent" },
}
// when
const result = syncTaskToTodo(task)
// then
expect(result?.priority).toBeUndefined()
})
it("handles missing metadata", () => {
// given
const task: Task = {
id: "T-no-meta",
subject: "No metadata",
description: "Task without metadata",
status: "pending",
blocks: [],
blockedBy: [],
}
// when
const result = syncTaskToTodo(task)
// then
expect(result?.priority).toBeUndefined()
})
it("uses subject as todo content", () => {
// given
const task: Task = {
id: "T-content",
subject: "This is the subject",
description: "This is the description",
status: "pending",
blocks: [],
blockedBy: [],
}
// when
const result = syncTaskToTodo(task)
// then
expect(result?.content).toBe("This is the subject")
})
})
describe("syncAllTasksToTodos", () => {
let mockCtx: any
beforeEach(() => {
mockCtx = {
client: {
session: {
todo: vi.fn(),
},
},
}
})
it("fetches current todos from OpenCode", async () => {
// given
const tasks: Task[] = [
{
id: "T-1",
subject: "Task 1",
description: "Description 1",
status: "pending",
blocks: [],
blockedBy: [],
},
]
const currentTodos: TodoInfo[] = [
{
id: "T-existing",
content: "Existing todo",
status: "pending",
},
]
mockCtx.client.session.todo.mockResolvedValue(currentTodos)
// when
await syncAllTasksToTodos(mockCtx, tasks, "session-1")
// then
expect(mockCtx.client.session.todo).toHaveBeenCalledWith({
path: { id: "session-1" },
})
})
it("handles API response with data property", async () => {
// given
const tasks: Task[] = []
const currentTodos: TodoInfo[] = [
{
id: "T-1",
content: "Todo 1",
status: "pending",
},
]
mockCtx.client.session.todo.mockResolvedValue({
data: currentTodos,
})
// when
await syncAllTasksToTodos(mockCtx, tasks, "session-1")
// then
expect(mockCtx.client.session.todo).toHaveBeenCalled()
})
it("gracefully handles fetch failure", async () => {
// given
const tasks: Task[] = [
{
id: "T-1",
subject: "Task 1",
description: "Description 1",
status: "pending",
blocks: [],
blockedBy: [],
},
]
mockCtx.client.session.todo.mockRejectedValue(new Error("API error"))
// when
const result = await syncAllTasksToTodos(mockCtx, tasks, "session-1")
// then
expect(result).toBeUndefined()
})
it("converts multiple tasks to todos", async () => {
// given
const tasks: Task[] = [
{
id: "T-1",
subject: "Task 1",
description: "Description 1",
status: "pending",
blocks: [],
blockedBy: [],
metadata: { priority: "high" },
},
{
id: "T-2",
subject: "Task 2",
description: "Description 2",
status: "in_progress",
blocks: [],
blockedBy: [],
metadata: { priority: "low" },
},
]
mockCtx.client.session.todo.mockResolvedValue([])
// when
await syncAllTasksToTodos(mockCtx, tasks, "session-1")
// then
expect(mockCtx.client.session.todo).toHaveBeenCalled()
})
it("removes deleted tasks from todo list", async () => {
// given
const tasks: Task[] = [
{
id: "T-1",
subject: "Task 1",
description: "Description 1",
status: "deleted",
blocks: [],
blockedBy: [],
},
]
const currentTodos: TodoInfo[] = [
{
id: "T-1",
content: "Task 1",
status: "pending",
},
]
mockCtx.client.session.todo.mockResolvedValue(currentTodos)
// when
await syncAllTasksToTodos(mockCtx, tasks, "session-1")
// then
expect(mockCtx.client.session.todo).toHaveBeenCalled()
})
it("preserves existing todos not in task list", async () => {
// given
const tasks: Task[] = [
{
id: "T-1",
subject: "Task 1",
description: "Description 1",
status: "pending",
blocks: [],
blockedBy: [],
},
]
const currentTodos: TodoInfo[] = [
{
id: "T-1",
content: "Task 1",
status: "pending",
},
{
id: "T-existing",
content: "Existing todo",
status: "pending",
},
]
mockCtx.client.session.todo.mockResolvedValue(currentTodos)
// when
await syncAllTasksToTodos(mockCtx, tasks, "session-1")
// then
expect(mockCtx.client.session.todo).toHaveBeenCalled()
})
it("handles empty task list", async () => {
// given
const tasks: Task[] = []
mockCtx.client.session.todo.mockResolvedValue([])
// when
await syncAllTasksToTodos(mockCtx, tasks, "session-1")
// then
expect(mockCtx.client.session.todo).toHaveBeenCalled()
})
it("handles undefined sessionID", async () => {
// given
const tasks: Task[] = [
{
id: "T-1",
subject: "Task 1",
description: "Description 1",
status: "pending",
blocks: [],
blockedBy: [],
},
]
mockCtx.client.session.todo.mockResolvedValue([])
// when
await syncAllTasksToTodos(mockCtx, tasks)
// then
expect(mockCtx.client.session.todo).toHaveBeenCalledWith({
path: { id: "" },
})
})
})

107
src/tools/task/todo-sync.ts Normal file
View File

@@ -0,0 +1,107 @@
import type { PluginInput } from "@opencode-ai/plugin"
import { log } from "../../shared/logger"
import type { Task } from "../../features/claude-tasks/types.ts"
export interface TodoInfo {
id: string
content: string
status: "pending" | "in_progress" | "completed" | "cancelled"
priority?: "low" | "medium" | "high"
}
function mapTaskStatusToTodoStatus(
taskStatus: Task["status"]
): TodoInfo["status"] | null {
switch (taskStatus) {
case "pending":
return "pending"
case "in_progress":
return "in_progress"
case "completed":
return "completed"
case "deleted":
return null
default:
return "pending"
}
}
function extractPriority(metadata?: Record<string, unknown>): TodoInfo["priority"] | undefined {
if (!metadata) return undefined
const priority = metadata.priority
if (typeof priority === "string" && ["low", "medium", "high"].includes(priority)) {
return priority as "low" | "medium" | "high"
}
return undefined
}
export function syncTaskToTodo(task: Task): TodoInfo | null {
const todoStatus = mapTaskStatusToTodoStatus(task.status)
if (todoStatus === null) {
return null
}
return {
id: task.id,
content: task.subject,
status: todoStatus,
priority: extractPriority(task.metadata),
}
}
export async function syncAllTasksToTodos(
ctx: PluginInput,
tasks: Task[],
sessionID?: string
): Promise<void> {
try {
let currentTodos: TodoInfo[] = []
try {
const response = await ctx.client.session.todo({
path: { id: sessionID || "" },
})
currentTodos = (response.data ?? response) as TodoInfo[]
} catch (err) {
log("[todo-sync] Failed to fetch current todos", {
error: String(err),
sessionID,
})
}
const newTodos: TodoInfo[] = []
const tasksToRemove = new Set<string>()
for (const task of tasks) {
const todo = syncTaskToTodo(task)
if (todo === null) {
tasksToRemove.add(task.id)
} else {
newTodos.push(todo)
}
}
const finalTodos: TodoInfo[] = []
const newTodoIds = new Set(newTodos.map(t => t.id))
for (const existing of currentTodos) {
if (!newTodoIds.has(existing.id) && !tasksToRemove.has(existing.id)) {
finalTodos.push(existing)
}
}
finalTodos.push(...newTodos)
log("[todo-sync] Synced todos", {
count: finalTodos.length,
sessionID,
})
} catch (err) {
log("[todo-sync] Error in syncAllTasksToTodos", {
error: String(err),
sessionID,
})
}
}

View File

@@ -0,0 +1,522 @@
import { describe, test, expect } from "bun:test"
import {
TaskStatusSchema,
TaskSchema,
TaskCreateInputSchema,
TaskUpdateInputSchema,
TaskListInputSchema,
TaskGetInputSchema,
TaskDeleteInputSchema,
} from "./types"
describe("TaskStatusSchema", () => {
test("accepts valid status values", () => {
//#given
const validStatuses = ["pending", "in_progress", "completed", "deleted"]
//#when
const results = validStatuses.map((status) => TaskStatusSchema.safeParse(status))
//#then
expect(results.every((r) => r.success)).toBe(true)
})
test("rejects invalid status values", () => {
//#given
const invalidStatuses = ["open", "done", "archived", "unknown"]
//#when
const results = invalidStatuses.map((status) => TaskStatusSchema.safeParse(status))
//#then
expect(results.every((r) => !r.success)).toBe(true)
})
})
describe("TaskSchema", () => {
test("validates complete task object with all fields", () => {
//#given
const task = {
id: "T-123",
subject: "Implement feature",
description: "Detailed description",
status: "pending" as const,
activeForm: "Implementing feature",
blocks: ["T-456"],
blockedBy: ["T-789"],
owner: "agent-name",
metadata: { priority: "high" },
repoURL: "https://github.com/example/repo",
parentID: "T-parent",
threadID: "thread-123",
}
//#when
const result = TaskSchema.safeParse(task)
//#then
expect(result.success).toBe(true)
})
test("validates task with only required fields", () => {
//#given
const task = {
id: "T-123",
subject: "Implement feature",
description: "Detailed description",
status: "pending" as const,
blocks: [],
blockedBy: [],
threadID: "thread-123",
}
//#when
const result = TaskSchema.safeParse(task)
//#then
expect(result.success).toBe(true)
})
test("rejects task missing required subject field", () => {
//#given
const task = {
id: "T-123",
description: "Detailed description",
status: "pending" as const,
blocks: [],
blockedBy: [],
threadID: "thread-123",
}
//#when
const result = TaskSchema.safeParse(task)
//#then
expect(result.success).toBe(false)
})
test("rejects task with invalid status", () => {
//#given
const task = {
id: "T-123",
subject: "Implement feature",
description: "Detailed description",
status: "open",
blocks: [],
blockedBy: [],
threadID: "thread-123",
}
//#when
const result = TaskSchema.safeParse(task)
//#then
expect(result.success).toBe(false)
})
test("validates blocks as array of strings", () => {
//#given
const task = {
id: "T-123",
subject: "Implement feature",
description: "Detailed description",
status: "pending" as const,
blocks: ["T-456", "T-789"],
blockedBy: [],
threadID: "thread-123",
}
//#when
const result = TaskSchema.safeParse(task)
//#then
expect(result.success).toBe(true)
})
test("validates blockedBy as array of strings", () => {
//#given
const task = {
id: "T-123",
subject: "Implement feature",
description: "Detailed description",
status: "pending" as const,
blocks: [],
blockedBy: ["T-456", "T-789"],
threadID: "thread-123",
}
//#when
const result = TaskSchema.safeParse(task)
//#then
expect(result.success).toBe(true)
})
test("validates metadata as record of unknown values", () => {
//#given
const task = {
id: "T-123",
subject: "Implement feature",
description: "Detailed description",
status: "pending" as const,
blocks: [],
blockedBy: [],
metadata: {
priority: "high",
tags: ["urgent", "backend"],
count: 42,
nested: { key: "value" },
},
threadID: "thread-123",
}
//#when
const result = TaskSchema.safeParse(task)
//#then
expect(result.success).toBe(true)
})
test("rejects extra fields with strict mode", () => {
//#given
const task = {
id: "T-123",
subject: "Implement feature",
description: "Detailed description",
status: "pending" as const,
blocks: [],
blockedBy: [],
threadID: "thread-123",
extraField: "should not be here",
}
//#when
const result = TaskSchema.safeParse(task)
//#then
expect(result.success).toBe(false)
})
test("defaults blocks to empty array", () => {
//#given
const task = {
id: "T-123",
subject: "Implement feature",
description: "Detailed description",
status: "pending" as const,
blockedBy: [],
threadID: "thread-123",
}
//#when
const result = TaskSchema.safeParse(task)
//#then
if (result.success) {
expect(result.data.blocks).toEqual([])
}
})
test("defaults blockedBy to empty array", () => {
//#given
const task = {
id: "T-123",
subject: "Implement feature",
description: "Detailed description",
status: "pending" as const,
blocks: [],
threadID: "thread-123",
}
//#when
const result = TaskSchema.safeParse(task)
//#then
if (result.success) {
expect(result.data.blockedBy).toEqual([])
}
})
})
describe("TaskCreateInputSchema", () => {
test("validates create input with required subject", () => {
//#given
const input = {
subject: "Implement feature",
}
//#when
const result = TaskCreateInputSchema.safeParse(input)
//#then
expect(result.success).toBe(true)
})
test("validates create input with all optional fields", () => {
//#given
const input = {
subject: "Implement feature",
description: "Detailed description",
blockedBy: ["T-456"],
blocks: ["T-789"],
activeForm: "Implementing feature",
owner: "agent-name",
metadata: { priority: "high" },
repoURL: "https://github.com/example/repo",
parentID: "T-parent",
}
//#when
const result = TaskCreateInputSchema.safeParse(input)
//#then
expect(result.success).toBe(true)
})
test("rejects create input without subject", () => {
//#given
const input = {
description: "Detailed description",
}
//#when
const result = TaskCreateInputSchema.safeParse(input)
//#then
expect(result.success).toBe(false)
})
test("accepts blockedBy as array of strings", () => {
//#given
const input = {
subject: "Implement feature",
blockedBy: ["T-456", "T-789"],
}
//#when
const result = TaskCreateInputSchema.safeParse(input)
//#then
expect(result.success).toBe(true)
})
test("accepts blocks as array of strings", () => {
//#given
const input = {
subject: "Implement feature",
blocks: ["T-456", "T-789"],
}
//#when
const result = TaskCreateInputSchema.safeParse(input)
//#then
expect(result.success).toBe(true)
})
})
describe("TaskUpdateInputSchema", () => {
test("validates update input with id and subject", () => {
//#given
const input = {
id: "T-123",
subject: "Updated subject",
}
//#when
const result = TaskUpdateInputSchema.safeParse(input)
//#then
expect(result.success).toBe(true)
})
test("validates update input with id only", () => {
//#given
const input = {
id: "T-123",
}
//#when
const result = TaskUpdateInputSchema.safeParse(input)
//#then
expect(result.success).toBe(true)
})
test("rejects update input without id", () => {
//#given
const input = {
subject: "Updated subject",
}
//#when
const result = TaskUpdateInputSchema.safeParse(input)
//#then
expect(result.success).toBe(false)
})
test("validates update with status change", () => {
//#given
const input = {
id: "T-123",
status: "in_progress" as const,
}
//#when
const result = TaskUpdateInputSchema.safeParse(input)
//#then
expect(result.success).toBe(true)
})
test("validates update with blockedBy change", () => {
//#given
const input = {
id: "T-123",
blockedBy: ["T-456", "T-789"],
}
//#when
const result = TaskUpdateInputSchema.safeParse(input)
//#then
expect(result.success).toBe(true)
})
test("validates update with blocks change", () => {
//#given
const input = {
id: "T-123",
blocks: ["T-456"],
}
//#when
const result = TaskUpdateInputSchema.safeParse(input)
//#then
expect(result.success).toBe(true)
})
test("validates update with multiple fields", () => {
//#given
const input = {
id: "T-123",
subject: "Updated subject",
description: "Updated description",
status: "completed" as const,
owner: "new-owner",
}
//#when
const result = TaskUpdateInputSchema.safeParse(input)
//#then
expect(result.success).toBe(true)
})
})
describe("TaskListInputSchema", () => {
test("validates empty list input", () => {
//#given
const input = {}
//#when
const result = TaskListInputSchema.safeParse(input)
//#then
expect(result.success).toBe(true)
})
test("validates list input with status filter", () => {
//#given
const input = {
status: "pending" as const,
}
//#when
const result = TaskListInputSchema.safeParse(input)
//#then
expect(result.success).toBe(true)
})
test("validates list input with parentID filter", () => {
//#given
const input = {
parentID: "T-parent",
}
//#when
const result = TaskListInputSchema.safeParse(input)
//#then
expect(result.success).toBe(true)
})
test("validates list input with both filters", () => {
//#given
const input = {
status: "in_progress" as const,
parentID: "T-parent",
}
//#when
const result = TaskListInputSchema.safeParse(input)
//#then
expect(result.success).toBe(true)
})
})
describe("TaskGetInputSchema", () => {
test("validates get input with id", () => {
//#given
const input = {
id: "T-123",
}
//#when
const result = TaskGetInputSchema.safeParse(input)
//#then
expect(result.success).toBe(true)
})
test("rejects get input without id", () => {
//#given
const input = {}
//#when
const result = TaskGetInputSchema.safeParse(input)
//#then
expect(result.success).toBe(false)
})
})
describe("TaskDeleteInputSchema", () => {
test("validates delete input with id", () => {
//#given
const input = {
id: "T-123",
}
//#when
const result = TaskDeleteInputSchema.safeParse(input)
//#then
expect(result.success).toBe(true)
})
test("rejects delete input without id", () => {
//#given
const input = {}
//#when
const result = TaskDeleteInputSchema.safeParse(input)
//#then
expect(result.success).toBe(false)
})
})

View File

@@ -22,10 +22,14 @@ export const TaskObjectSchema = z
export type TaskObject = z.infer<typeof TaskObjectSchema>
// Claude Code style aliases
export const TaskSchema = TaskObjectSchema
export type Task = TaskObject
// Action input schemas
export const TaskCreateInputSchema = z.object({
subject: z.string(),
description: z.string(),
description: z.string().optional(),
activeForm: z.string().optional(),
blocks: z.array(z.string()).optional(),
blockedBy: z.array(z.string()).optional(),