diff --git a/assets/oh-my-opencode.schema.json b/assets/oh-my-opencode.schema.json index 6c1e62f7b..f0d4d3c87 100644 --- a/assets/oh-my-opencode.schema.json +++ b/assets/oh-my-opencode.schema.json @@ -97,6 +97,12 @@ ] } }, + "disabled_tools": { + "type": "array", + "items": { + "type": "string" + } + }, "agents": { "type": "object", "properties": { diff --git a/docs/configurations.md b/docs/configurations.md index 84eda6ce7..7111f5c8b 100644 --- a/docs/configurations.md +++ b/docs/configurations.md @@ -1017,9 +1017,9 @@ Configure notification behavior for background task completion. | -------------- | ------- | ---------------------------------------------------------------------------------------------- | | `force_enable` | `false` | Force enable session-notification even if external notification plugins are detected. Default: `false`. | -## Sisyphus Tasks & Swarm +## Sisyphus Tasks -Configure Sisyphus Tasks and Swarm systems for advanced task management and multi-agent orchestration. +Configure Sisyphus Tasks system for advanced task management. ```json { @@ -1028,11 +1028,6 @@ Configure Sisyphus Tasks and Swarm systems for advanced task management and mult "enabled": false, "storage_path": ".sisyphus/tasks", "claude_code_compat": false - }, - "swarm": { - "enabled": false, - "storage_path": ".sisyphus/teams", - "ui_mode": "toast" } } } @@ -1046,14 +1041,6 @@ Configure Sisyphus Tasks and Swarm systems for advanced task management and mult | `storage_path` | `.sisyphus/tasks` | Storage path for tasks (relative to project root) | | `claude_code_compat` | `false` | Enable Claude Code path compatibility mode | -### Swarm Configuration - -| Option | Default | Description | -| -------------- | ------------------ | -------------------------------------------------------------- | -| `enabled` | `false` | Enable Sisyphus Swarm system for multi-agent orchestration | -| `storage_path` | `.sisyphus/teams` | Storage path for teams (relative to project root) | -| `ui_mode` | `toast` | UI mode: `toast` (notifications), `tmux` (panes), or `both` | - ## MCPs Exa, Context7 and grep.app MCP enabled by default. diff --git a/package.json b/package.json index 94ab57f66..6b612dc71 100644 --- a/package.json +++ b/package.json @@ -70,7 +70,7 @@ "devDependencies": { "@types/js-yaml": "^4.0.9", "@types/picomatch": "^3.0.2", - "bun-types": "latest", + "bun-types": "1.3.6", "typescript": "^5.7.3" }, "optionalDependencies": { diff --git a/src/config/index.ts b/src/config/index.ts index 88bfd765f..5f881831b 100644 --- a/src/config/index.ts +++ b/src/config/index.ts @@ -27,4 +27,6 @@ export type { RalphLoopConfig, TmuxConfig, TmuxLayout, + SisyphusConfig, + SisyphusTasksConfig, } from "./schema" diff --git a/src/config/schema.ts b/src/config/schema.ts index 4d4f5fc90..7c84588f5 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -92,6 +92,7 @@ export const HookNameSchema = z.enum([ "atlas", "unstable-agent-babysitter", "stop-continuation-guard", + "tasks-todowrite-disabler", ]) export const BuiltinCommandNameSchema = z.enum([ @@ -352,34 +353,26 @@ export const TmuxConfigSchema = z.object({ }) export const SisyphusTasksConfigSchema = z.object({ - /** Enable Sisyphus Tasks system (default: false) */ - enabled: z.boolean().default(false), /** Storage path for tasks (default: .sisyphus/tasks) */ storage_path: z.string().default(".sisyphus/tasks"), /** Enable Claude Code path compatibility mode */ claude_code_compat: z.boolean().default(false), }) -export const SisyphusSwarmConfigSchema = z.object({ - /** Enable Sisyphus Swarm system (default: false) */ - enabled: z.boolean().default(false), - /** Storage path for teams (default: .sisyphus/teams) */ - storage_path: z.string().default(".sisyphus/teams"), - /** UI mode: toast notifications, tmux panes, or both */ - ui_mode: z.enum(["toast", "tmux", "both"]).default("toast"), -}) - export const SisyphusConfigSchema = z.object({ tasks: SisyphusTasksConfigSchema.optional(), - swarm: SisyphusSwarmConfigSchema.optional(), }) export const OhMyOpenCodeConfigSchema = z.object({ $schema: z.string().optional(), + /** Enable new task system (default: false) */ + new_task_system_enabled: z.boolean().default(false), disabled_mcps: z.array(AnyMcpNameSchema).optional(), disabled_agents: z.array(BuiltinAgentNameSchema).optional(), disabled_skills: z.array(BuiltinSkillNameSchema).optional(), disabled_hooks: z.array(HookNameSchema).optional(), disabled_commands: z.array(BuiltinCommandNameSchema).optional(), + /** Disable specific tools by name (e.g., ["todowrite", "todoread"]) */ + disabled_tools: z.array(z.string()).optional(), agents: AgentOverridesSchema.optional(), categories: CategoriesConfigSchema.optional(), claude_code: ClaudeCodeConfigSchema.optional(), @@ -424,7 +417,6 @@ export type BrowserAutomationConfig = z.infer export type TmuxLayout = z.infer export type SisyphusTasksConfig = z.infer -export type SisyphusSwarmConfig = z.infer export type SisyphusConfig = z.infer export { AnyMcpNameSchema, type AnyMcpName, McpNameSchema, type McpName } from "../mcp/types" diff --git a/src/features/claude-tasks/AGENTS.md b/src/features/claude-tasks/AGENTS.md new file mode 100644 index 000000000..abf364298 --- /dev/null +++ b/src/features/claude-tasks/AGENTS.md @@ -0,0 +1,102 @@ +# CLAUDE TASKS FEATURE KNOWLEDGE BASE + +## OVERVIEW + +Claude Code compatible task schema and storage. Provides core task management utilities used by task-related tools and features. + +## STRUCTURE + +``` +claude-tasks/ +├── types.ts # Task schema (Zod) +├── types.test.ts # Schema validation tests (8 tests) +├── storage.ts # File operations +├── storage.test.ts # Storage tests (14 tests) +└── index.ts # Barrel exports +``` + +## TASK SCHEMA + +```typescript +type TaskStatus = "pending" | "in_progress" | "completed" | "deleted" + +interface Task { + id: string + subject: string // Imperative: "Run tests" + description: string + status: TaskStatus + activeForm?: string // Present continuous: "Running tests" + blocks: string[] // Task IDs this task blocks + blockedBy: string[] // Task IDs blocking this task + owner?: string // Agent name + metadata?: Record +} +``` + +**Key Differences from Legacy**: +- `subject` (was `title`) +- `blockedBy` (was `dependsOn`) +- No `parentID`, `repoURL`, `threadID` fields + +## STORAGE UTILITIES + +### getTaskDir(teamName, config) + +Returns: `.sisyphus/tasks/{teamName}` (or custom path from config) + +### readJsonSafe(filePath, schema) + +- Returns parsed & validated data or `null` +- Safe for missing files, invalid JSON, schema violations + +### writeJsonAtomic(filePath, data) + +- Atomic write via temp file + rename +- Creates parent directories automatically +- Cleans up temp file on error + +### acquireLock(dirPath) + +- File-based lock: `.lock` file with timestamp +- 30-second stale threshold +- Returns `{ acquired: boolean, release: () => void }` + +## TESTING + +**types.test.ts** (8 tests): +- Valid status enum values +- Required vs optional fields +- Array validation (blocks, blockedBy) +- Schema rejection for invalid data + +**storage.test.ts** (14 tests): +- Path construction +- Safe JSON reading (missing files, invalid JSON, schema failures) +- Atomic writes (directory creation, overwrites) +- Lock acquisition (fresh locks, stale locks, release) + +## USAGE + +```typescript +import { TaskSchema, getTaskDir, readJsonSafe, writeJsonAtomic, acquireLock } from "./features/claude-tasks" + +const taskDir = getTaskDir("my-team", config) +const lock = acquireLock(taskDir) + +try { + const task = readJsonSafe(join(taskDir, "1.json"), TaskSchema) + if (task) { + task.status = "completed" + writeJsonAtomic(join(taskDir, "1.json"), task) + } +} finally { + lock.release() +} +``` + +## ANTI-PATTERNS + +- Direct fs operations (use storage utilities) +- Skipping lock acquisition for writes +- Ignoring null returns from readJsonSafe +- Using old schema field names (title, dependsOn) diff --git a/src/features/claude-tasks/index.ts b/src/features/claude-tasks/index.ts new file mode 100644 index 000000000..f0d374eaa --- /dev/null +++ b/src/features/claude-tasks/index.ts @@ -0,0 +1,2 @@ +export * from "./types" +export * from "./storage" diff --git a/src/features/claude-tasks/storage.test.ts b/src/features/claude-tasks/storage.test.ts new file mode 100644 index 000000000..9fbc9b4a8 --- /dev/null +++ b/src/features/claude-tasks/storage.test.ts @@ -0,0 +1,361 @@ +import { describe, test, expect, beforeEach, afterEach } from "bun:test" +import { existsSync, mkdirSync, rmSync, writeFileSync } from "fs" +import { join } from "path" +import { z } from "zod" +import { getTaskDir, readJsonSafe, writeJsonAtomic, acquireLock, generateTaskId, listTaskFiles } from "./storage" +import type { OhMyOpenCodeConfig } from "../../config/schema" + +const TEST_DIR = ".test-claude-tasks" +const TEST_DIR_ABS = join(process.cwd(), TEST_DIR) + +describe("getTaskDir", () => { + test("returns correct path for default config", () => { + //#given + const config: Partial = {} + + //#when + const result = getTaskDir(config) + + //#then + expect(result).toBe(join(process.cwd(), ".sisyphus/tasks")) + }) + + test("returns correct path with custom storage_path", () => { + //#given + const config: Partial = { + sisyphus: { + tasks: { + storage_path: ".custom/tasks", + claude_code_compat: false, + }, + }, + } + + //#when + const result = getTaskDir(config) + + //#then + expect(result).toBe(join(process.cwd(), ".custom/tasks")) + }) + + test("returns correct path with default config parameter", () => { + //#when + const result = getTaskDir() + + //#then + expect(result).toBe(join(process.cwd(), ".sisyphus/tasks")) + }) +}) + +describe("generateTaskId", () => { + test("generates task ID with T- prefix and UUID", () => { + //#when + const taskId = generateTaskId() + + //#then + expect(taskId).toMatch(/^T-[a-f0-9-]{36}$/) + }) + + test("generates unique task IDs", () => { + //#when + const id1 = generateTaskId() + const id2 = generateTaskId() + + //#then + expect(id1).not.toBe(id2) + }) +}) + +describe("listTaskFiles", () => { + beforeEach(() => { + if (existsSync(TEST_DIR_ABS)) { + rmSync(TEST_DIR_ABS, { recursive: true, force: true }) + } + }) + + afterEach(() => { + if (existsSync(TEST_DIR_ABS)) { + rmSync(TEST_DIR_ABS, { recursive: true, force: true }) + } + }) + + test("returns empty array for non-existent directory", () => { + //#given + const config: Partial = { + new_task_system_enabled: false, + sisyphus: { tasks: { storage_path: TEST_DIR, claude_code_compat: false } } + } + + //#when + const result = listTaskFiles(config) + + //#then + expect(result).toEqual([]) + }) + + test("returns empty array for directory with no task files", () => { + //#given + const config: Partial = { + new_task_system_enabled: false, + sisyphus: { tasks: { storage_path: TEST_DIR, claude_code_compat: false } } + } + mkdirSync(TEST_DIR_ABS, { recursive: true }) + writeFileSync(join(TEST_DIR_ABS, "other.json"), "{}", "utf-8") + + //#when + const result = listTaskFiles(config) + + //#then + expect(result).toEqual([]) + }) + + test("lists task files with T- prefix and .json extension", () => { + //#given + const config: Partial = { + new_task_system_enabled: false, + sisyphus: { tasks: { storage_path: TEST_DIR, claude_code_compat: false } } + } + mkdirSync(TEST_DIR_ABS, { recursive: true }) + writeFileSync(join(TEST_DIR_ABS, "T-abc123.json"), "{}", "utf-8") + writeFileSync(join(TEST_DIR_ABS, "T-def456.json"), "{}", "utf-8") + writeFileSync(join(TEST_DIR_ABS, "other.json"), "{}", "utf-8") + writeFileSync(join(TEST_DIR_ABS, "notes.md"), "# notes", "utf-8") + + //#when + const result = listTaskFiles(config) + + //#then + expect(result).toHaveLength(2) + expect(result).toContain("T-abc123") + expect(result).toContain("T-def456") + }) + + test("returns task IDs without .json extension", () => { + //#given + const config: Partial = { + new_task_system_enabled: false, + sisyphus: { tasks: { storage_path: TEST_DIR, claude_code_compat: false } } + } + mkdirSync(TEST_DIR_ABS, { recursive: true }) + writeFileSync(join(TEST_DIR_ABS, "T-test-id.json"), "{}", "utf-8") + + //#when + const result = listTaskFiles(config) + + //#then + expect(result[0]).toBe("T-test-id") + expect(result[0]).not.toContain(".json") + }) +}) + +describe("readJsonSafe", () => { + const testSchema = z.object({ + id: z.string(), + value: z.number(), + }) + + beforeEach(() => { + if (existsSync(TEST_DIR_ABS)) { + rmSync(TEST_DIR_ABS, { recursive: true, force: true }) + } + mkdirSync(TEST_DIR_ABS, { recursive: true }) + }) + + afterEach(() => { + if (existsSync(TEST_DIR_ABS)) { + rmSync(TEST_DIR_ABS, { recursive: true, force: true }) + } + }) + + test("returns null for non-existent file", () => { + //#given + const filePath = join(TEST_DIR_ABS, "nonexistent.json") + + //#when + const result = readJsonSafe(filePath, testSchema) + + //#then + expect(result).toBeNull() + }) + + test("returns parsed data for valid file", () => { + //#given + const filePath = join(TEST_DIR_ABS, "valid.json") + const data = { id: "test", value: 42 } + writeFileSync(filePath, JSON.stringify(data), "utf-8") + + //#when + const result = readJsonSafe(filePath, testSchema) + + //#then + expect(result).toEqual(data) + }) + + test("returns null for invalid JSON", () => { + //#given + const filePath = join(TEST_DIR_ABS, "invalid.json") + writeFileSync(filePath, "{ invalid json", "utf-8") + + //#when + const result = readJsonSafe(filePath, testSchema) + + //#then + expect(result).toBeNull() + }) + + test("returns null for data that fails schema validation", () => { + //#given + const filePath = join(TEST_DIR_ABS, "invalid-schema.json") + const data = { id: "test", value: "not-a-number" } + writeFileSync(filePath, JSON.stringify(data), "utf-8") + + //#when + const result = readJsonSafe(filePath, testSchema) + + //#then + expect(result).toBeNull() + }) +}) + +describe("writeJsonAtomic", () => { + beforeEach(() => { + if (existsSync(TEST_DIR_ABS)) { + rmSync(TEST_DIR_ABS, { recursive: true, force: true }) + } + }) + + afterEach(() => { + if (existsSync(TEST_DIR_ABS)) { + rmSync(TEST_DIR_ABS, { recursive: true, force: true }) + } + }) + + test("creates directory if it does not exist", () => { + //#given + const filePath = join(TEST_DIR_ABS, "nested", "dir", "file.json") + const data = { test: "data" } + + //#when + writeJsonAtomic(filePath, data) + + //#then + expect(existsSync(filePath)).toBe(true) + }) + + test("writes data atomically", async () => { + //#given + const filePath = join(TEST_DIR_ABS, "atomic.json") + const data = { id: "test", value: 123 } + + //#when + writeJsonAtomic(filePath, data) + + //#then + expect(existsSync(filePath)).toBe(true) + const content = await Bun.file(filePath).text() + expect(JSON.parse(content)).toEqual(data) + }) + + test("overwrites existing file", async () => { + //#given + const filePath = join(TEST_DIR_ABS, "overwrite.json") + mkdirSync(TEST_DIR_ABS, { recursive: true }) + writeFileSync(filePath, JSON.stringify({ old: "data" }), "utf-8") + + //#when + const newData = { new: "data" } + writeJsonAtomic(filePath, newData) + + //#then + const content = await Bun.file(filePath).text() + expect(JSON.parse(content)).toEqual(newData) + }) +}) + +describe("acquireLock", () => { + beforeEach(() => { + if (existsSync(TEST_DIR_ABS)) { + rmSync(TEST_DIR_ABS, { recursive: true, force: true }) + } + mkdirSync(TEST_DIR_ABS, { recursive: true }) + }) + + afterEach(() => { + if (existsSync(TEST_DIR_ABS)) { + rmSync(TEST_DIR_ABS, { recursive: true, force: true }) + } + }) + + test("acquires lock when no lock exists", () => { + //#given + const dirPath = TEST_DIR_ABS + + //#when + const lock = acquireLock(dirPath) + + //#then + expect(lock.acquired).toBe(true) + expect(existsSync(join(dirPath, ".lock"))).toBe(true) + + //#cleanup + lock.release() + }) + + test("fails to acquire lock when fresh lock exists", () => { + //#given + const dirPath = TEST_DIR + const firstLock = acquireLock(dirPath) + + //#when + const secondLock = acquireLock(dirPath) + + //#then + expect(secondLock.acquired).toBe(false) + + //#cleanup + firstLock.release() + }) + + test("acquires lock when stale lock exists (>30s)", () => { + //#given + const dirPath = TEST_DIR + const lockPath = join(dirPath, ".lock") + const staleTimestamp = Date.now() - 31000 // 31 seconds ago + writeFileSync(lockPath, JSON.stringify({ timestamp: staleTimestamp }), "utf-8") + + //#when + const lock = acquireLock(dirPath) + + //#then + expect(lock.acquired).toBe(true) + + //#cleanup + lock.release() + }) + + test("release removes lock file", () => { + //#given + const dirPath = TEST_DIR + const lock = acquireLock(dirPath) + const lockPath = join(dirPath, ".lock") + + //#when + lock.release() + + //#then + expect(existsSync(lockPath)).toBe(false) + }) + + test("release is safe to call multiple times", () => { + //#given + const dirPath = TEST_DIR + const lock = acquireLock(dirPath) + + //#when + lock.release() + lock.release() + + //#then + expect(existsSync(join(dirPath, ".lock"))).toBe(false) + }) +}) diff --git a/src/features/claude-tasks/storage.ts b/src/features/claude-tasks/storage.ts new file mode 100644 index 000000000..a8e1cbab9 --- /dev/null +++ b/src/features/claude-tasks/storage.ts @@ -0,0 +1,112 @@ +import { join, dirname } from "path" +import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, unlinkSync, readdirSync } from "fs" +import { randomUUID } from "crypto" +import type { z } from "zod" +import type { OhMyOpenCodeConfig } from "../../config/schema" + +export function getTaskDir(config: Partial = {}): string { + const tasksConfig = config.sisyphus?.tasks + const storagePath = tasksConfig?.storage_path ?? ".sisyphus/tasks" + return join(process.cwd(), storagePath) +} + +export function ensureDir(dirPath: string): void { + if (!existsSync(dirPath)) { + mkdirSync(dirPath, { recursive: true }) + } +} + +export function readJsonSafe(filePath: string, schema: z.ZodType): T | null { + try { + if (!existsSync(filePath)) { + return null + } + + const content = readFileSync(filePath, "utf-8") + const parsed = JSON.parse(content) + const result = schema.safeParse(parsed) + + if (!result.success) { + return null + } + + return result.data + } catch { + return null + } +} + +export function writeJsonAtomic(filePath: string, data: unknown): void { + const dir = dirname(filePath) + ensureDir(dir) + + const tempPath = `${filePath}.tmp.${Date.now()}` + + try { + writeFileSync(tempPath, JSON.stringify(data, null, 2), "utf-8") + renameSync(tempPath, filePath) + } catch (error) { + try { + if (existsSync(tempPath)) { + unlinkSync(tempPath) + } + } catch { + // Ignore cleanup errors + } + throw error + } +} + +const STALE_LOCK_THRESHOLD_MS = 30000 + +export function generateTaskId(): string { + return `T-${randomUUID()}` +} + +export function listTaskFiles(config: Partial = {}): string[] { + const dir = getTaskDir(config) + if (!existsSync(dir)) return [] + return readdirSync(dir) + .filter((f) => f.endsWith('.json') && f.startsWith('T-')) + .map((f) => f.replace('.json', '')) +} + +export function acquireLock(dirPath: string): { acquired: boolean; release: () => void } { + const lockPath = join(dirPath, ".lock") + const now = Date.now() + + if (existsSync(lockPath)) { + try { + const lockContent = readFileSync(lockPath, "utf-8") + const lockData = JSON.parse(lockContent) + const lockAge = now - lockData.timestamp + + if (lockAge <= STALE_LOCK_THRESHOLD_MS) { + return { + acquired: false, + release: () => { + // No-op release for failed acquisition + }, + } + } + } catch { + // If lock file is corrupted, treat as stale and override + } + } + + ensureDir(dirPath) + writeFileSync(lockPath, JSON.stringify({ timestamp: now }), "utf-8") + + return { + acquired: true, + release: () => { + try { + if (existsSync(lockPath)) { + unlinkSync(lockPath) + } + } catch { + // Ignore cleanup errors + } + }, + } +} diff --git a/src/features/claude-tasks/types.test.ts b/src/features/claude-tasks/types.test.ts new file mode 100644 index 000000000..0efa156b2 --- /dev/null +++ b/src/features/claude-tasks/types.test.ts @@ -0,0 +1,174 @@ +import { describe, test, expect } from "bun:test" +import { TaskSchema, TaskStatusSchema, type Task, type TaskStatus } from "./types" + +describe("TaskStatusSchema", () => { + test("accepts valid status values", () => { + //#given + const validStatuses: TaskStatus[] = ["pending", "in_progress", "completed", "deleted"] + + //#when + const results = validStatuses.map((status) => TaskStatusSchema.safeParse(status)) + + //#then + results.forEach((result) => { + expect(result.success).toBe(true) + }) + }) + + test("rejects invalid status values", () => { + //#given + const invalidStatuses = ["open", "closed", "archived", ""] + + //#when + const results = invalidStatuses.map((status) => TaskStatusSchema.safeParse(status)) + + //#then + results.forEach((result) => { + expect(result.success).toBe(false) + }) + }) +}) + +describe("TaskSchema", () => { + test("parses valid Task with all required fields", () => { + //#given + const validTask = { + id: "1", + subject: "Run tests", + description: "Execute test suite", + status: "pending" as TaskStatus, + blocks: [], + blockedBy: [], + } + + //#when + const result = TaskSchema.safeParse(validTask) + + //#then + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.id).toBe("1") + expect(result.data.subject).toBe("Run tests") + expect(result.data.status).toBe("pending") + expect(result.data.blocks).toEqual([]) + expect(result.data.blockedBy).toEqual([]) + } + }) + + test("parses Task with optional fields", () => { + //#given + const taskWithOptionals: Task = { + id: "2", + subject: "Deploy app", + description: "Deploy to production", + status: "in_progress", + activeForm: "Deploying app", + blocks: ["3", "4"], + blockedBy: ["1"], + owner: "sisyphus", + metadata: { priority: "high", tags: ["urgent"] }, + } + + //#when + const result = TaskSchema.safeParse(taskWithOptionals) + + //#then + expect(result.success).toBe(true) + if (result.success) { + expect(result.data.activeForm).toBe("Deploying app") + expect(result.data.owner).toBe("sisyphus") + expect(result.data.metadata).toEqual({ priority: "high", tags: ["urgent"] }) + } + }) + + test("validates blocks and blockedBy as arrays", () => { + //#given + const taskWithDeps = { + id: "3", + subject: "Test feature", + description: "Test new feature", + status: "pending" as TaskStatus, + blocks: ["4", "5", "6"], + blockedBy: ["1", "2"], + } + + //#when + const result = TaskSchema.safeParse(taskWithDeps) + + //#then + expect(result.success).toBe(true) + if (result.success) { + expect(Array.isArray(result.data.blocks)).toBe(true) + expect(result.data.blocks).toHaveLength(3) + expect(Array.isArray(result.data.blockedBy)).toBe(true) + expect(result.data.blockedBy).toHaveLength(2) + } + }) + + test("rejects Task missing required fields", () => { + //#given + const invalidTasks = [ + { subject: "No ID", description: "Missing id", status: "pending", blocks: [], blockedBy: [] }, + { id: "1", description: "No subject", status: "pending", blocks: [], blockedBy: [] }, + { id: "1", subject: "No description", status: "pending", blocks: [], blockedBy: [] }, + { id: "1", subject: "No status", description: "Missing status", blocks: [], blockedBy: [] }, + { id: "1", subject: "No blocks", description: "Missing blocks", status: "pending", blockedBy: [] }, + { id: "1", subject: "No blockedBy", description: "Missing blockedBy", status: "pending", blocks: [] }, + ] + + //#when + const results = invalidTasks.map((task) => TaskSchema.safeParse(task)) + + //#then + results.forEach((result) => { + expect(result.success).toBe(false) + }) + }) + + test("rejects Task with invalid status", () => { + //#given + const taskWithInvalidStatus = { + id: "1", + subject: "Test", + description: "Test task", + status: "invalid_status", + blocks: [], + blockedBy: [], + } + + //#when + const result = TaskSchema.safeParse(taskWithInvalidStatus) + + //#then + expect(result.success).toBe(false) + }) + + test("rejects Task with non-array blocks or blockedBy", () => { + //#given + const taskWithInvalidBlocks = { + id: "1", + subject: "Test", + description: "Test task", + status: "pending", + blocks: "not-an-array", + blockedBy: [], + } + + const taskWithInvalidBlockedBy = { + id: "1", + subject: "Test", + description: "Test task", + status: "pending", + blocks: [], + blockedBy: "not-an-array", + } + + //#when + const result1 = TaskSchema.safeParse(taskWithInvalidBlocks) + const result2 = TaskSchema.safeParse(taskWithInvalidBlockedBy) + + //#then + expect(result1.success).toBe(false) + expect(result2.success).toBe(false) + }) +}) diff --git a/src/features/claude-tasks/types.ts b/src/features/claude-tasks/types.ts new file mode 100644 index 000000000..2343ac97f --- /dev/null +++ b/src/features/claude-tasks/types.ts @@ -0,0 +1,20 @@ +import { z } from "zod" + +export const TaskStatusSchema = z.enum(["pending", "in_progress", "completed", "deleted"]) +export type TaskStatus = z.infer + +export const TaskSchema = z + .object({ + id: z.string(), + subject: z.string(), + description: z.string(), + status: TaskStatusSchema, + activeForm: z.string().optional(), + blocks: z.array(z.string()), + blockedBy: z.array(z.string()), + owner: z.string().optional(), + metadata: z.record(z.string(), z.unknown()).optional(), + }) + .strict() + +export type Task = z.infer diff --git a/src/features/sisyphus-swarm/mailbox/types.test.ts b/src/features/sisyphus-swarm/mailbox/types.test.ts deleted file mode 100644 index 4dab18c4e..000000000 --- a/src/features/sisyphus-swarm/mailbox/types.test.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { describe, it, expect } from "bun:test" -import { - MailboxMessageSchema, - PermissionRequestSchema, - PermissionResponseSchema, - ShutdownRequestSchema, - TaskAssignmentSchema, - JoinRequestSchema, - ProtocolMessageSchema, -} from "./types" - -describe("MailboxMessageSchema", () => { - // given a valid mailbox message - // when parsing - // then it should succeed - it("parses valid message", () => { - const msg = { - from: "agent-001", - text: '{"type":"idle_notification"}', - timestamp: "2026-01-27T10:00:00Z", - read: false, - } - expect(MailboxMessageSchema.safeParse(msg).success).toBe(true) - }) - - // given a message with optional color - // when parsing - // then it should succeed - it("parses message with color", () => { - const msg = { - from: "agent-001", - text: "{}", - timestamp: "2026-01-27T10:00:00Z", - color: "blue", - read: true, - } - expect(MailboxMessageSchema.safeParse(msg).success).toBe(true) - }) -}) - -describe("ProtocolMessageSchema", () => { - // given permission_request message - // when parsing - // then it should succeed - it("parses permission_request", () => { - const msg = { - type: "permission_request", - requestId: "req-123", - toolName: "Bash", - input: { command: "rm -rf /" }, - agentId: "agent-001", - timestamp: Date.now(), - } - expect(PermissionRequestSchema.safeParse(msg).success).toBe(true) - }) - - // given permission_response message - // when parsing - // then it should succeed - it("parses permission_response", () => { - const approved = { - type: "permission_response", - requestId: "req-123", - decision: "approved", - updatedInput: { command: "ls" }, - } - expect(PermissionResponseSchema.safeParse(approved).success).toBe(true) - - const rejected = { - type: "permission_response", - requestId: "req-123", - decision: "rejected", - feedback: "Too dangerous", - } - expect(PermissionResponseSchema.safeParse(rejected).success).toBe(true) - }) - - // given shutdown_request message - // when parsing - // then it should succeed - it("parses shutdown messages", () => { - const request = { type: "shutdown_request" } - expect(ShutdownRequestSchema.safeParse(request).success).toBe(true) - }) - - // given task_assignment message - // when parsing - // then it should succeed - it("parses task_assignment", () => { - const msg = { - type: "task_assignment", - taskId: "1", - subject: "Fix bug", - description: "Fix the auth bug", - assignedBy: "team-lead", - timestamp: Date.now(), - } - expect(TaskAssignmentSchema.safeParse(msg).success).toBe(true) - }) - - // given join_request message - // when parsing - // then it should succeed - it("parses join_request", () => { - const msg = { - type: "join_request", - agentName: "new-agent", - sessionId: "sess-123", - } - expect(JoinRequestSchema.safeParse(msg).success).toBe(true) - }) -}) diff --git a/src/features/sisyphus-swarm/mailbox/types.ts b/src/features/sisyphus-swarm/mailbox/types.ts deleted file mode 100644 index ae222818b..000000000 --- a/src/features/sisyphus-swarm/mailbox/types.ts +++ /dev/null @@ -1,153 +0,0 @@ -import { z } from "zod" - -export const MailboxMessageSchema = z.object({ - from: z.string(), - text: z.string(), - timestamp: z.string(), - color: z.string().optional(), - read: z.boolean(), -}) - -export type MailboxMessage = z.infer - -export const PermissionRequestSchema = z.object({ - type: z.literal("permission_request"), - requestId: z.string(), - toolName: z.string(), - input: z.unknown(), - agentId: z.string(), - timestamp: z.number(), -}) - -export type PermissionRequest = z.infer - -export const PermissionResponseSchema = z.object({ - type: z.literal("permission_response"), - requestId: z.string(), - decision: z.enum(["approved", "rejected"]), - updatedInput: z.unknown().optional(), - feedback: z.string().optional(), - permissionUpdates: z.unknown().optional(), -}) - -export type PermissionResponse = z.infer - -export const ShutdownRequestSchema = z.object({ - type: z.literal("shutdown_request"), -}) - -export type ShutdownRequest = z.infer - -export const ShutdownApprovedSchema = z.object({ - type: z.literal("shutdown_approved"), -}) - -export type ShutdownApproved = z.infer - -export const ShutdownRejectedSchema = z.object({ - type: z.literal("shutdown_rejected"), - reason: z.string().optional(), -}) - -export type ShutdownRejected = z.infer - -export const TaskAssignmentSchema = z.object({ - type: z.literal("task_assignment"), - taskId: z.string(), - subject: z.string(), - description: z.string(), - assignedBy: z.string(), - timestamp: z.number(), -}) - -export type TaskAssignment = z.infer - -export const TaskCompletedSchema = z.object({ - type: z.literal("task_completed"), - taskId: z.string(), - agentId: z.string(), - timestamp: z.number(), -}) - -export type TaskCompleted = z.infer - -export const IdleNotificationSchema = z.object({ - type: z.literal("idle_notification"), -}) - -export type IdleNotification = z.infer - -export const JoinRequestSchema = z.object({ - type: z.literal("join_request"), - agentName: z.string(), - sessionId: z.string(), -}) - -export type JoinRequest = z.infer - -export const JoinApprovedSchema = z.object({ - type: z.literal("join_approved"), - agentName: z.string(), - teamName: z.string(), -}) - -export type JoinApproved = z.infer - -export const JoinRejectedSchema = z.object({ - type: z.literal("join_rejected"), - reason: z.string().optional(), -}) - -export type JoinRejected = z.infer - -export const PlanApprovalRequestSchema = z.object({ - type: z.literal("plan_approval_request"), - requestId: z.string(), - plan: z.string(), - agentId: z.string(), -}) - -export type PlanApprovalRequest = z.infer - -export const PlanApprovalResponseSchema = z.object({ - type: z.literal("plan_approval_response"), - requestId: z.string(), - decision: z.enum(["approved", "rejected"]), - feedback: z.string().optional(), -}) - -export type PlanApprovalResponse = z.infer - -export const ModeSetRequestSchema = z.object({ - type: z.literal("mode_set_request"), - mode: z.enum(["acceptEdits", "bypassPermissions", "default", "delegate", "dontAsk", "plan"]), -}) - -export type ModeSetRequest = z.infer - -export const TeamPermissionUpdateSchema = z.object({ - type: z.literal("team_permission_update"), - permissions: z.record(z.string(), z.unknown()), -}) - -export type TeamPermissionUpdate = z.infer - -export const ProtocolMessageSchema = z.discriminatedUnion("type", [ - PermissionRequestSchema, - PermissionResponseSchema, - ShutdownRequestSchema, - ShutdownApprovedSchema, - ShutdownRejectedSchema, - TaskAssignmentSchema, - TaskCompletedSchema, - IdleNotificationSchema, - JoinRequestSchema, - JoinApprovedSchema, - JoinRejectedSchema, - PlanApprovalRequestSchema, - PlanApprovalResponseSchema, - ModeSetRequestSchema, - TeamPermissionUpdateSchema, -]) - -export type ProtocolMessage = z.infer diff --git a/src/features/sisyphus-tasks/storage.test.ts b/src/features/sisyphus-tasks/storage.test.ts deleted file mode 100644 index 7ffe023cf..000000000 --- a/src/features/sisyphus-tasks/storage.test.ts +++ /dev/null @@ -1,178 +0,0 @@ -import { describe, it, expect, beforeEach, afterEach } from "bun:test" -import { join } from "path" -import { mkdirSync, rmSync, existsSync, writeFileSync, readFileSync } from "fs" -import { z } from "zod" -import { - getTaskDir, - getTaskPath, - getTeamDir, - getInboxPath, - ensureDir, - readJsonSafe, - writeJsonAtomic, -} from "./storage" - -const TEST_DIR = join(import.meta.dirname, ".test-storage") - -describe("Storage Utilities", () => { - beforeEach(() => { - rmSync(TEST_DIR, { recursive: true, force: true }) - mkdirSync(TEST_DIR, { recursive: true }) - }) - - afterEach(() => { - rmSync(TEST_DIR, { recursive: true, force: true }) - }) - - describe("getTaskDir", () => { - // given default config (no claude_code_compat) - // when getting task directory - // then it should return .sisyphus/tasks/{listId} - it("returns sisyphus path by default", () => { - const config = { sisyphus: { tasks: { storage_path: ".sisyphus/tasks" } } } - const result = getTaskDir("list-123", config as any) - expect(result).toContain(".sisyphus/tasks/list-123") - }) - - // given claude_code_compat enabled - // when getting task directory - // then it should return Claude Code path - it("returns claude code path when compat enabled", () => { - const config = { - sisyphus: { - tasks: { - storage_path: ".sisyphus/tasks", - claude_code_compat: true, - }, - }, - } - const result = getTaskDir("list-123", config as any) - expect(result).toContain(".cache/claude-code/tasks/list-123") - }) - }) - - describe("getTaskPath", () => { - // given list and task IDs - // when getting task path - // then it should return path to task JSON file - it("returns path to task JSON", () => { - const config = { sisyphus: { tasks: { storage_path: ".sisyphus/tasks" } } } - const result = getTaskPath("list-123", "1", config as any) - expect(result).toContain("list-123/1.json") - }) - }) - - describe("getTeamDir", () => { - // given team name and default config - // when getting team directory - // then it should return .sisyphus/teams/{teamName} - it("returns sisyphus team path", () => { - const config = { sisyphus: { swarm: { storage_path: ".sisyphus/teams" } } } - const result = getTeamDir("my-team", config as any) - expect(result).toContain(".sisyphus/teams/my-team") - }) - }) - - describe("getInboxPath", () => { - // given team and agent names - // when getting inbox path - // then it should return path to inbox JSON file - it("returns path to inbox JSON", () => { - const config = { sisyphus: { swarm: { storage_path: ".sisyphus/teams" } } } - const result = getInboxPath("my-team", "agent-001", config as any) - expect(result).toContain("my-team/inboxes/agent-001.json") - }) - }) - - describe("ensureDir", () => { - // given a non-existent directory path - // when calling ensureDir - // then it should create the directory - it("creates directory if not exists", () => { - const dirPath = join(TEST_DIR, "new-dir", "nested") - ensureDir(dirPath) - expect(existsSync(dirPath)).toBe(true) - }) - - // given an existing directory - // when calling ensureDir - // then it should not throw - it("does not throw for existing directory", () => { - const dirPath = join(TEST_DIR, "existing") - mkdirSync(dirPath, { recursive: true }) - expect(() => ensureDir(dirPath)).not.toThrow() - }) - }) - - describe("readJsonSafe", () => { - // given a valid JSON file matching schema - // when reading with readJsonSafe - // then it should return parsed object - it("reads and parses valid JSON", () => { - const testSchema = z.object({ name: z.string(), value: z.number() }) - const filePath = join(TEST_DIR, "test.json") - writeFileSync(filePath, JSON.stringify({ name: "test", value: 42 })) - - const result = readJsonSafe(filePath, testSchema) - expect(result).toEqual({ name: "test", value: 42 }) - }) - - // given a non-existent file - // when reading with readJsonSafe - // then it should return null - it("returns null for non-existent file", () => { - const testSchema = z.object({ name: z.string() }) - const result = readJsonSafe(join(TEST_DIR, "missing.json"), testSchema) - expect(result).toBeNull() - }) - - // given invalid JSON content - // when reading with readJsonSafe - // then it should return null - it("returns null for invalid JSON", () => { - const testSchema = z.object({ name: z.string() }) - const filePath = join(TEST_DIR, "invalid.json") - writeFileSync(filePath, "not valid json") - - const result = readJsonSafe(filePath, testSchema) - expect(result).toBeNull() - }) - - // given JSON that doesn't match schema - // when reading with readJsonSafe - // then it should return null - it("returns null for schema mismatch", () => { - const testSchema = z.object({ name: z.string(), required: z.number() }) - const filePath = join(TEST_DIR, "mismatch.json") - writeFileSync(filePath, JSON.stringify({ name: "test" })) - - const result = readJsonSafe(filePath, testSchema) - expect(result).toBeNull() - }) - }) - - describe("writeJsonAtomic", () => { - // given data to write - // when calling writeJsonAtomic - // then it should write to file atomically - it("writes JSON atomically", () => { - const filePath = join(TEST_DIR, "atomic.json") - const data = { key: "value", number: 123 } - - writeJsonAtomic(filePath, data) - - const content = readFileSync(filePath, "utf-8") - expect(JSON.parse(content)).toEqual(data) - }) - - // given a deeply nested path - // when calling writeJsonAtomic - // then it should create parent directories - it("creates parent directories", () => { - const filePath = join(TEST_DIR, "deep", "nested", "file.json") - writeJsonAtomic(filePath, { test: true }) - - expect(existsSync(filePath)).toBe(true) - }) - }) -}) diff --git a/src/features/sisyphus-tasks/storage.ts b/src/features/sisyphus-tasks/storage.ts deleted file mode 100644 index 64c5f01dd..000000000 --- a/src/features/sisyphus-tasks/storage.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { join, dirname } from "path" -import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, unlinkSync } from "fs" -import { homedir } from "os" -import type { z } from "zod" -import type { OhMyOpenCodeConfig } from "../../config/schema" - -export function getTaskDir(listId: string, config: Partial): string { - const tasksConfig = config.sisyphus?.tasks - - if (tasksConfig?.claude_code_compat) { - return join(homedir(), ".cache", "claude-code", "tasks", listId) - } - - const storagePath = tasksConfig?.storage_path ?? ".sisyphus/tasks" - return join(process.cwd(), storagePath, listId) -} - -export function getTaskPath(listId: string, taskId: string, config: Partial): string { - return join(getTaskDir(listId, config), `${taskId}.json`) -} - -export function getTeamDir(teamName: string, config: Partial): string { - const swarmConfig = config.sisyphus?.swarm - - if (swarmConfig?.storage_path?.includes("claude")) { - return join(homedir(), ".claude", "teams", teamName) - } - - const storagePath = swarmConfig?.storage_path ?? ".sisyphus/teams" - return join(process.cwd(), storagePath, teamName) -} - -export function getInboxPath(teamName: string, agentName: string, config: Partial): string { - return join(getTeamDir(teamName, config), "inboxes", `${agentName}.json`) -} - -export function ensureDir(dirPath: string): void { - if (!existsSync(dirPath)) { - mkdirSync(dirPath, { recursive: true }) - } -} - -export function readJsonSafe(filePath: string, schema: z.ZodType): T | null { - try { - if (!existsSync(filePath)) { - return null - } - - const content = readFileSync(filePath, "utf-8") - const parsed = JSON.parse(content) - const result = schema.safeParse(parsed) - - if (!result.success) { - return null - } - - return result.data - } catch { - return null - } -} - -export function writeJsonAtomic(filePath: string, data: unknown): void { - const dir = dirname(filePath) - ensureDir(dir) - - const tempPath = `${filePath}.tmp.${Date.now()}` - - try { - writeFileSync(tempPath, JSON.stringify(data, null, 2), "utf-8") - renameSync(tempPath, filePath) - } catch (error) { - try { - if (existsSync(tempPath)) { - unlinkSync(tempPath) - } - } catch { - // Ignore cleanup errors - } - throw error - } -} diff --git a/src/features/sisyphus-tasks/types.test.ts b/src/features/sisyphus-tasks/types.test.ts deleted file mode 100644 index 6f8df450d..000000000 --- a/src/features/sisyphus-tasks/types.test.ts +++ /dev/null @@ -1,82 +0,0 @@ -import { describe, it, expect } from "bun:test" -import { TaskSchema, TaskStatusSchema, type Task } from "./types" - -describe("TaskSchema", () => { - // given a valid task object - // when parsing with TaskSchema - // then it should succeed - it("parses valid task object", () => { - const validTask = { - id: "1", - subject: "Fix authentication bug", - description: "Users report 401 errors", - status: "pending", - blocks: [], - blockedBy: [], - } - - const result = TaskSchema.safeParse(validTask) - expect(result.success).toBe(true) - }) - - // given a task with all optional fields - // when parsing with TaskSchema - // then it should succeed - it("parses task with optional fields", () => { - const taskWithOptionals = { - id: "2", - subject: "Add unit tests", - description: "Write tests for auth module", - activeForm: "Adding unit tests", - owner: "agent-001", - status: "in_progress", - blocks: ["3"], - blockedBy: ["1"], - metadata: { priority: "high", labels: ["bug"] }, - } - - const result = TaskSchema.safeParse(taskWithOptionals) - expect(result.success).toBe(true) - }) - - // given an invalid status value - // when parsing with TaskSchema - // then it should fail - it("rejects invalid status", () => { - const invalidTask = { - id: "1", - subject: "Test", - description: "Test", - status: "invalid_status", - blocks: [], - blockedBy: [], - } - - const result = TaskSchema.safeParse(invalidTask) - expect(result.success).toBe(false) - }) - - // given missing required fields - // when parsing with TaskSchema - // then it should fail - it("rejects missing required fields", () => { - const invalidTask = { - id: "1", - // missing subject, description, status, blocks, blockedBy - } - - const result = TaskSchema.safeParse(invalidTask) - expect(result.success).toBe(false) - }) -}) - -describe("TaskStatusSchema", () => { - // given valid status values - // when parsing - // then all should succeed - it("accepts valid statuses", () => { - expect(TaskStatusSchema.safeParse("pending").success).toBe(true) - expect(TaskStatusSchema.safeParse("in_progress").success).toBe(true) - expect(TaskStatusSchema.safeParse("completed").success).toBe(true) - }) -}) diff --git a/src/features/sisyphus-tasks/types.ts b/src/features/sisyphus-tasks/types.ts deleted file mode 100644 index b6349aee8..000000000 --- a/src/features/sisyphus-tasks/types.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { z } from "zod" - -export const TaskStatusSchema = z.enum(["pending", "in_progress", "completed"]) -export type TaskStatus = z.infer - -export const TaskSchema = z.object({ - id: z.string(), - subject: z.string(), - description: z.string(), - activeForm: z.string().optional(), - owner: z.string().optional(), - status: TaskStatusSchema, - blocks: z.array(z.string()), - blockedBy: z.array(z.string()), - metadata: z.record(z.string(), z.unknown()).optional(), -}) - -export type Task = z.infer - -export const TaskCreateInputSchema = z.object({ - subject: z.string().describe("Task title"), - description: z.string().describe("Detailed description"), - activeForm: z.string().optional().describe("Text shown when in progress"), - metadata: z.record(z.string(), z.unknown()).optional(), -}) - -export type TaskCreateInput = z.infer - -export const TaskUpdateInputSchema = z.object({ - taskId: z.string().describe("Task ID to update"), - subject: z.string().optional(), - description: z.string().optional(), - activeForm: z.string().optional(), - status: z.enum(["pending", "in_progress", "completed", "deleted"]).optional(), - addBlocks: z.array(z.string()).optional().describe("Task IDs this task will block"), - addBlockedBy: z.array(z.string()).optional().describe("Task IDs that block this task"), - owner: z.string().optional(), - metadata: z.record(z.string(), z.unknown()).optional(), -}) - -export type TaskUpdateInput = z.infer diff --git a/src/hooks/task-reminder/index.test.ts b/src/hooks/task-reminder/index.test.ts new file mode 100644 index 000000000..aab0f8ee0 --- /dev/null +++ b/src/hooks/task-reminder/index.test.ts @@ -0,0 +1,125 @@ +import { describe, test, expect, beforeEach } from "bun:test" +import { createTaskReminderHook } from "./index" +import type { PluginInput } from "@opencode-ai/plugin" + +const mockCtx = {} as PluginInput + +describe("TaskReminderHook", () => { + let hook: ReturnType + + beforeEach(() => { + hook = createTaskReminderHook(mockCtx) + }) + + test("does not inject reminder before 10 turns", async () => { + //#given + const sessionID = "test-session" + const output = { output: "Result" } + + //#when + for (let i = 0; i < 9; i++) { + await hook["tool.execute.after"]?.( + { tool: "bash", sessionID, callID: `call-${i}` }, + output + ) + } + + //#then + expect(output.output).not.toContain("task tools haven't been used") + }) + + test("injects reminder after 10 turns without task tool usage", async () => { + //#given + const sessionID = "test-session" + const output = { output: "Result" } + + //#when + for (let i = 0; i < 10; i++) { + await hook["tool.execute.after"]?.( + { tool: "bash", sessionID, callID: `call-${i}` }, + output + ) + } + + //#then + expect(output.output).toContain("task tools haven't been used") + }) + + test("resets counter when task tool is used", async () => { + //#given + const sessionID = "test-session" + const output = { output: "Result" } + + //#when + for (let i = 0; i < 5; i++) { + await hook["tool.execute.after"]?.( + { tool: "bash", sessionID, callID: `call-${i}` }, + output + ) + } + await hook["tool.execute.after"]?.( + { tool: "task", sessionID, callID: "call-task" }, + output + ) + for (let i = 0; i < 9; i++) { + await hook["tool.execute.after"]?.( + { tool: "bash", sessionID, callID: `call-after-${i}` }, + output + ) + } + + //#then + expect(output.output).not.toContain("task tools haven't been used") + }) + + test("resets counter after injecting reminder", async () => { + //#given + const sessionID = "test-session" + const output1 = { output: "Result 1" } + const output2 = { output: "Result 2" } + + //#when + for (let i = 0; i < 10; i++) { + await hook["tool.execute.after"]?.( + { tool: "bash", sessionID, callID: `call-1-${i}` }, + output1 + ) + } + for (let i = 0; i < 9; i++) { + await hook["tool.execute.after"]?.( + { tool: "bash", sessionID, callID: `call-2-${i}` }, + output2 + ) + } + + //#then + expect(output1.output).toContain("task tools haven't been used") + expect(output2.output).not.toContain("task tools haven't been used") + }) + + test("tracks separate counters per session", async () => { + //#given + const session1 = "session-1" + const session2 = "session-2" + const output1 = { output: "Result 1" } + const output2 = { output: "Result 2" } + + //#when + for (let i = 0; i < 10; i++) { + await hook["tool.execute.after"]?.( + { tool: "bash", sessionID: session1, callID: `call-${i}` }, + output1 + ) + } + for (let i = 0; i < 5; i++) { + await hook["tool.execute.after"]?.( + { tool: "bash", sessionID: session2, callID: `call-${i}` }, + output2 + ) + } + + //#then + expect(output1.output).toContain("task tools haven't been used") + expect(output2.output).not.toContain("task tools haven't been used") + }) +}) diff --git a/src/hooks/task-reminder/index.ts b/src/hooks/task-reminder/index.ts new file mode 100644 index 000000000..05b421a07 --- /dev/null +++ b/src/hooks/task-reminder/index.ts @@ -0,0 +1,45 @@ +import type { PluginInput } from "@opencode-ai/plugin" + +const TASK_TOOLS = new Set(["task"]) +const TURN_THRESHOLD = 10 +const REMINDER_MESSAGE = ` + +The task tools haven't been used recently. If you're working on tasks that would benefit from tracking progress, consider using TaskCreate to add new tasks and TaskUpdate to update task status (set to in_progress when starting, completed when done).` + +interface ToolExecuteInput { + tool: string + sessionID: string + callID: string +} + +interface ToolExecuteOutput { + output: string +} + +export function createTaskReminderHook(_ctx: PluginInput) { + const sessionCounters = new Map() + + const toolExecuteAfter = async (input: ToolExecuteInput, output: ToolExecuteOutput) => { + const { tool, sessionID } = input + const toolLower = tool.toLowerCase() + + if (TASK_TOOLS.has(toolLower)) { + sessionCounters.set(sessionID, 0) + return + } + + const currentCount = sessionCounters.get(sessionID) ?? 0 + const newCount = currentCount + 1 + + if (newCount >= TURN_THRESHOLD) { + output.output += REMINDER_MESSAGE + sessionCounters.set(sessionID, 0) + } else { + sessionCounters.set(sessionID, newCount) + } + } + + return { + "tool.execute.after": toolExecuteAfter, + } +} diff --git a/src/hooks/task-resume-info/index.ts b/src/hooks/task-resume-info/index.ts index fb32f6456..f1194c088 100644 --- a/src/hooks/task-resume-info/index.ts +++ b/src/hooks/task-resume-info/index.ts @@ -1,4 +1,4 @@ -const TARGET_TOOLS = ["task", "Task", "call_omo_agent", "delegate_task"] +const TARGET_TOOLS = ["task", "Task", "task_tool", "call_omo_agent", "delegate_task"] const SESSION_ID_PATTERNS = [ /Session ID: (ses_[a-zA-Z0-9_-]+)/, diff --git a/src/hooks/tool-output-truncator.ts b/src/hooks/tool-output-truncator.ts index c2837991a..8f8c300da 100644 --- a/src/hooks/tool-output-truncator.ts +++ b/src/hooks/tool-output-truncator.ts @@ -39,6 +39,7 @@ export function createToolOutputTruncatorHook(ctx: PluginInput, options?: ToolOu output: { title: string; output: string; metadata: unknown } ) => { if (!truncateAll && !TRUNCATABLE_TOOLS.includes(input.tool)) return + if (typeof output.output !== 'string') return try { const targetMaxTokens = TOOL_SPECIFIC_MAX_TOKENS[input.tool] ?? DEFAULT_MAX_TOKENS diff --git a/src/index.ts b/src/index.ts index 89727ae52..b571759cf 100644 --- a/src/index.ts +++ b/src/index.ts @@ -73,6 +73,7 @@ import { interactive_bash, startTmuxCheck, lspManager, + createTask, } from "./tools"; import { BackgroundManager } from "./features/background-agent"; import { SkillMcpManager } from "./features/skill-mcp-manager"; @@ -419,6 +420,9 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => { modelCacheState, }); + const newTaskSystemEnabled = pluginConfig.new_task_system_enabled ?? false; + const taskTool = newTaskSystemEnabled ? createTask(pluginConfig) : null; + return { tool: { ...builtinTools, @@ -430,6 +434,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => { skill_mcp: skillMcpTool, slashcommand: slashcommandTool, interactive_bash, + ...(taskTool ? { task: taskTool } : {}), }, "chat.message": async (input, output) => { diff --git a/src/plugin-config.ts b/src/plugin-config.ts index bc1e5dc7e..7a6bd1b39 100644 --- a/src/plugin-config.ts +++ b/src/plugin-config.ts @@ -113,7 +113,7 @@ export function loadPluginConfig( // Load user config first (base) let config: OhMyOpenCodeConfig = - loadConfigFromPath(userConfigPath, ctx) ?? {}; + loadConfigFromPath(userConfigPath, ctx) ?? { new_task_system_enabled: false }; // Override with project config const projectConfig = loadConfigFromPath(projectConfigPath, ctx); diff --git a/src/plugin-handlers/config-handler.ts b/src/plugin-handlers/config-handler.ts index 744a377c3..0c0da2e81 100644 --- a/src/plugin-handlers/config-handler.ts +++ b/src/plugin-handlers/config-handler.ts @@ -403,6 +403,8 @@ export function createConfigHandler(deps: ConfigHandlerDeps) { LspHover: false, LspCodeActions: false, LspCodeActionResolve: false, + "task_*": false, + teammate: false, }; type AgentWithPermission = { permission?: Record }; @@ -417,11 +419,11 @@ export function createConfigHandler(deps: ConfigHandlerDeps) { } if (agentResult["atlas"]) { const agent = agentResult["atlas"] as AgentWithPermission; - agent.permission = { ...agent.permission, task: "deny", call_omo_agent: "deny", delegate_task: "allow" }; + agent.permission = { ...agent.permission, task: "deny", call_omo_agent: "deny", delegate_task: "allow", "task_*": "allow", teammate: "allow" }; } if (agentResult.sisyphus) { const agent = agentResult.sisyphus as AgentWithPermission; - agent.permission = { ...agent.permission, call_omo_agent: "deny", delegate_task: "allow", question: "allow" }; + agent.permission = { ...agent.permission, call_omo_agent: "deny", delegate_task: "allow", question: "allow", "task_*": "allow", teammate: "allow" }; } if (agentResult.hephaestus) { const agent = agentResult.hephaestus as AgentWithPermission; @@ -429,11 +431,11 @@ export function createConfigHandler(deps: ConfigHandlerDeps) { } if (agentResult["prometheus"]) { const agent = agentResult["prometheus"] as AgentWithPermission; - agent.permission = { ...agent.permission, call_omo_agent: "deny", delegate_task: "allow", question: "allow" }; + agent.permission = { ...agent.permission, call_omo_agent: "deny", delegate_task: "allow", question: "allow", "task_*": "allow", teammate: "allow" }; } if (agentResult["sisyphus-junior"]) { const agent = agentResult["sisyphus-junior"] as AgentWithPermission; - agent.permission = { ...agent.permission, delegate_task: "allow" }; + agent.permission = { ...agent.permission, delegate_task: "allow", "task_*": "allow", teammate: "allow" }; } config.permission = { diff --git a/src/shared/dynamic-truncator.ts b/src/shared/dynamic-truncator.ts index 33481ea92..017bca162 100644 --- a/src/shared/dynamic-truncator.ts +++ b/src/shared/dynamic-truncator.ts @@ -43,6 +43,10 @@ export function truncateToTokenLimit( maxTokens: number, preserveHeaderLines = 3, ): TruncationResult { + if (typeof output !== 'string') { + return { result: String(output ?? ''), truncated: false }; + } + const currentTokens = estimateTokens(output); if (currentTokens <= maxTokens) { @@ -147,6 +151,10 @@ export async function dynamicTruncate( output: string, options: TruncationOptions = {}, ): Promise { + if (typeof output !== 'string') { + return { result: String(output ?? ''), truncated: false }; + } + const { targetMaxTokens = DEFAULT_TARGET_MAX_TOKENS, preserveHeaderLines = 3, diff --git a/src/tools/delegate-task/types.ts b/src/tools/delegate-task/types.ts index 296fcc683..aa0c512d9 100644 --- a/src/tools/delegate-task/types.ts +++ b/src/tools/delegate-task/types.ts @@ -13,6 +13,10 @@ export interface DelegateTaskArgs { session_id?: string command?: string load_skills: string[] + execute?: { + task_id: string + task_dir?: string + } } export interface ToolContextWithMetadata { diff --git a/src/tools/index.ts b/src/tools/index.ts index 3eab094fc..fc415b90e 100644 --- a/src/tools/index.ts +++ b/src/tools/index.ts @@ -73,3 +73,5 @@ export const builtinTools: Record = { session_search, session_info, } + +export { createTask } from "./task" diff --git a/src/tools/task/index.ts b/src/tools/task/index.ts new file mode 100644 index 000000000..0df3ba704 --- /dev/null +++ b/src/tools/task/index.ts @@ -0,0 +1,2 @@ +export { createTask } from "./task" +export type { TaskObject, TaskStatus, TaskCreateInput, TaskListInput, TaskGetInput, TaskUpdateInput, TaskDeleteInput } from "./types" diff --git a/src/tools/task/task.test.ts b/src/tools/task/task.test.ts new file mode 100644 index 000000000..1d1570551 --- /dev/null +++ b/src/tools/task/task.test.ts @@ -0,0 +1,768 @@ +import { describe, test, expect, beforeEach, afterEach } from "bun:test" +import { existsSync, rmSync, mkdirSync, writeFileSync, readdirSync } from "fs" +import { join } from "path" +import type { TaskObject } from "./types" +import { createTask } from "./task" + +const TEST_STORAGE = ".test-task-tool" +const TEST_DIR = join(process.cwd(), TEST_STORAGE) +const TEST_CONFIG = { + new_task_system_enabled: true, + sisyphus: { + tasks: { + storage_path: TEST_STORAGE, + claude_code_compat: true, + }, + }, +} +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_tool", () => { + let taskTool: ReturnType + + beforeEach(() => { + if (existsSync(TEST_STORAGE)) { + rmSync(TEST_STORAGE, { recursive: true, force: true }) + } + mkdirSync(TEST_DIR, { recursive: true }) + taskTool = createTask(TEST_CONFIG) + }) + + async function createTestTask(title: string, overrides: Partial[0]> = {}): Promise { + const args = { + action: "create" as const, + title, + ...overrides, + } + const resultStr = await taskTool.execute(args, TEST_CONTEXT) + const result = JSON.parse(resultStr) + return (result as { task: TaskObject }).task.id + } + + afterEach(() => { + if (existsSync(TEST_STORAGE)) { + rmSync(TEST_STORAGE, { recursive: true, force: true }) + } + }) + + // ============================================================================ + // CREATE ACTION TESTS + // ============================================================================ + + describe("create action", () => { + test("creates task with required title field", async () => { + //#given + const args = { + action: "create" as const, + title: "Implement authentication", + } + + //#when + const resultStr = await taskTool.execute(args, TEST_CONTEXT) + const result = JSON.parse(resultStr) + + //#then + expect(result).toHaveProperty("task") + expect(result.task).toHaveProperty("id") + expect(result.task.title).toBe("Implement authentication") + expect(result.task.status).toBe("open") + }) + + test("auto-generates T-{uuid} format ID", async () => { + //#given + const args = { + action: "create" as const, + title: "Test task", + } + + //#when + const resultStr = await taskTool.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 = { + action: "create" as const, + title: "Test task", + } + + //#when + const resultStr = await taskTool.execute(args, TEST_CONTEXT) + const result = JSON.parse(resultStr) + + //#then + expect(result.task).toHaveProperty("threadID") + expect(typeof result.task.threadID).toBe("string") + }) + + test("sets status to open by default", async () => { + //#given + const args = { + action: "create" as const, + title: "Test task", + } + + //#when + const resultStr = await taskTool.execute(args, TEST_CONTEXT) + const result = JSON.parse(resultStr) + + //#then + expect(result.task.status).toBe("open") + }) + + test("stores optional description field", async () => { + //#given + const args = { + action: "create" as const, + title: "Test task", + description: "Detailed description of the task", + } + + //#when + const resultStr = await taskTool.execute(args, TEST_CONTEXT) + const result = JSON.parse(resultStr) + + //#then + expect(result.task.description).toBe("Detailed description of the task") + }) + + test("stores dependsOn array", async () => { + //#given + const args = { + action: "create" as const, + title: "Test task", + dependsOn: ["T-dep1", "T-dep2"], + } + + //#when + const resultStr = await taskTool.execute(args, TEST_CONTEXT) + const result = JSON.parse(resultStr) + + //#then + expect(result.task.dependsOn).toEqual(["T-dep1", "T-dep2"]) + }) + + test("stores parentID when provided", async () => { + //#given + const args = { + action: "create" as const, + title: "Subtask", + parentID: "T-parent123", + } + + //#when + const resultStr = await taskTool.execute(args, TEST_CONTEXT) + const result = JSON.parse(resultStr) + + //#then + expect(result.task.parentID).toBe("T-parent123") + }) + + test("stores repoURL when provided", async () => { + //#given + const args = { + action: "create" as const, + title: "Test task", + repoURL: "https://github.com/code-yeongyu/oh-my-opencode", + } + + //#when + const resultStr = await taskTool.execute(args, TEST_CONTEXT) + const result = JSON.parse(resultStr) + + //#then + expect(result.task.repoURL).toBe("https://github.com/code-yeongyu/oh-my-opencode") + }) + + test("returns result as JSON string with task property", async () => { + //#given + const args = { + action: "create" as const, + title: "Test task", + } + + //#when + const resultStr = await taskTool.execute(args, TEST_CONTEXT) + + //#then + expect(typeof resultStr).toBe("string") + const result = JSON.parse(resultStr) + expect(result).toHaveProperty("task") + }) + + test("initializes dependsOn as empty array when not provided", async () => { + //#given + const args = { + action: "create" as const, + title: "Test task", + } + + //#when + const resultStr = await taskTool.execute(args, TEST_CONTEXT) + const result = JSON.parse(resultStr) + + //#then + expect(result.task.dependsOn).toEqual([]) + }) + }) + + // ============================================================================ + // LIST ACTION TESTS + // ============================================================================ + + describe("list action", () => { + test("returns all non-completed tasks by default", async () => { + //#given + const args = { + action: "list" as const, + } + + //#when + const resultStr = await taskTool.execute(args, TEST_CONTEXT) + const result = JSON.parse(resultStr) + + //#then + expect(result).toHaveProperty("tasks") + expect(Array.isArray(result.tasks)).toBe(true) + }) + + test("excludes completed tasks from list", async () => { + //#given + const args = { + action: "list" as const, + } + + //#when + const resultStr = await taskTool.execute(args, TEST_CONTEXT) + const result = JSON.parse(resultStr) + + //#then + const completedTasks = result.tasks.filter((t: TaskObject) => t.status === "completed") + expect(completedTasks.length).toBe(0) + }) + + test("applies ready filter when requested", async () => { + //#given + const args = { + action: "list" as const, + ready: true, + } + + //#when + const resultStr = await taskTool.execute(args, TEST_CONTEXT) + const result = JSON.parse(resultStr) + + //#then + expect(result).toHaveProperty("tasks") + expect(Array.isArray(result.tasks)).toBe(true) + }) + + test("respects limit parameter", async () => { + //#given + const args = { + action: "list" as const, + limit: 5, + } + + //#when + const resultStr = await taskTool.execute(args, TEST_CONTEXT) + const result = JSON.parse(resultStr) + + //#then + expect(result.tasks.length).toBeLessThanOrEqual(5) + }) + + test("returns result as JSON string with tasks array", async () => { + //#given + const args = { + action: "list" as const, + } + + //#when + const resultStr = await taskTool.execute(args, TEST_CONTEXT) + + //#then + expect(typeof resultStr).toBe("string") + const result = JSON.parse(resultStr) + expect(Array.isArray(result.tasks)).toBe(true) + }) + + test("filters by status when provided", async () => { + //#given + const args = { + action: "list" as const, + status: "in_progress" as const, + } + + //#when + const resultStr = await taskTool.execute(args, TEST_CONTEXT) + const result = JSON.parse(resultStr) + + //#then + const allInProgress = result.tasks.every((t: TaskObject) => t.status === "in_progress") + expect(allInProgress).toBe(true) + }) + }) + + // ============================================================================ + // GET ACTION TESTS + // ============================================================================ + + describe("get action", () => { + test("returns task by ID", async () => { + //#given + const testId = await createTestTask("Test task") + const args = { + action: "get" as const, + id: testId, + } + + //#when + const resultStr = await taskTool.execute(args, TEST_CONTEXT) + const result = JSON.parse(resultStr) + + //#then + expect(result).toHaveProperty("task") + }) + + test("returns null for non-existent task", async () => { + //#given + const args = { + action: "get" as const, + id: "T-nonexistent", + } + + //#when + const resultStr = await taskTool.execute(args, TEST_CONTEXT) + const result = JSON.parse(resultStr) + + //#then + expect(result.task).toBeNull() + }) + + test("returns result as JSON string with task property", async () => { + //#given + const testId = await createTestTask("Test task") + const args = { + action: "get" as const, + id: testId, + } + + //#when + const resultStr = await taskTool.execute(args, TEST_CONTEXT) + + //#then + expect(typeof resultStr).toBe("string") + const result = JSON.parse(resultStr) + expect(result).toHaveProperty("task") + }) + + test("returns complete task object with all fields", async () => { + //#given + const args = { + action: "get" as const, + id: "T-test123", + } + + //#when + const resultStr = await taskTool.execute(args, TEST_CONTEXT) + const result = JSON.parse(resultStr) + + //#then + if (result.task !== null) { + expect(result.task).toHaveProperty("id") + expect(result.task).toHaveProperty("title") + expect(result.task).toHaveProperty("status") + expect(result.task).toHaveProperty("threadID") + } + }) + }) + + // ============================================================================ + // UPDATE ACTION TESTS + // ============================================================================ + + describe("update action", () => { + test("updates task title", async () => { + //#given + const testId = await createTestTask("Test task") + const args = { + action: "update" as const, + id: testId, + title: "Updated title", + } + + //#when + const resultStr = await taskTool.execute(args, TEST_CONTEXT) + const result = JSON.parse(resultStr) + + //#then + expect(result).toHaveProperty("task") + expect(result.task.title).toBe("Updated title") + }) + + test("updates task description", async () => { + //#given + const testId = await createTestTask("Test task", { description: "Initial description" }) + const args = { + action: "update" as const, + id: testId, + description: "Updated description", + } + + //#when + const resultStr = await taskTool.execute(args, TEST_CONTEXT) + const result = JSON.parse(resultStr) + + //#then + expect(result.task.description).toBe("Updated description") + }) + + test("updates task status", async () => { + //#given + const testId = await createTestTask("Test task") + const args = { + action: "update" as const, + id: testId, + status: "in_progress" as const, + } + + //#when + const resultStr = await taskTool.execute(args, TEST_CONTEXT) + const result = JSON.parse(resultStr) + + //#then + expect(result.task.status).toBe("in_progress") + }) + + test("updates dependsOn array", async () => { + //#given + const testId = await createTestTask("Test task") + const args = { + action: "update" as const, + id: testId, + dependsOn: ["T-dep1", "T-dep2", "T-dep3"], + } + + //#when + const resultStr = await taskTool.execute(args, TEST_CONTEXT) + const result = JSON.parse(resultStr) + + //#then + expect(result.task.dependsOn).toEqual(["T-dep1", "T-dep2", "T-dep3"]) + }) + + test("returns error for non-existent task", async () => { + //#given + const args = { + action: "update" as const, + id: "T-nonexistent", + title: "New title", + } + + //#when + const resultStr = await taskTool.execute(args, TEST_CONTEXT) + const result = JSON.parse(resultStr) + + //#then + expect(result).toHaveProperty("error") + expect(result.error).toBe("task_not_found") + }) + + test("returns result as JSON string with task property", async () => { + //#given + const testId = await createTestTask("Test task") + const args = { + action: "update" as const, + id: testId, + title: "Updated", + } + + //#when + const resultStr = await taskTool.execute(args, TEST_CONTEXT) + + //#then + expect(typeof resultStr).toBe("string") + const result = JSON.parse(resultStr) + expect(result).toHaveProperty("task") + }) + + test("updates multiple fields at once", async () => { + //#given + const testId = await createTestTask("Test task") + const args = { + action: "update" as const, + id: testId, + title: "New title", + description: "New description", + status: "completed" as const, + } + + //#when + const resultStr = await taskTool.execute(args, TEST_CONTEXT) + const result = JSON.parse(resultStr) + + //#then + expect(result.task.title).toBe("New title") + expect(result.task.description).toBe("New description") + expect(result.task.status).toBe("completed") + }) + }) + + // ============================================================================ + // DELETE ACTION TESTS + // ============================================================================ + + describe("delete action", () => { + test("removes task file physically", async () => { + //#given + const testId = await createTestTask("Test task") + const args = { + action: "delete" as const, + id: testId, + } + + //#when + const resultStr = await taskTool.execute(args, TEST_CONTEXT) + const result = JSON.parse(resultStr) + + //#then + expect(result).toHaveProperty("success") + expect(result.success).toBe(true) + }) + + test("returns success true on successful deletion", async () => { + //#given + const testId = await createTestTask("Test task") + const args = { + action: "delete" as const, + id: testId, + } + + //#when + const resultStr = await taskTool.execute(args, TEST_CONTEXT) + const result = JSON.parse(resultStr) + + //#then + expect(result.success).toBe(true) + }) + + test("returns error for non-existent task", async () => { + //#given + const args = { + action: "delete" as const, + id: "T-nonexistent", + } + + //#when + const resultStr = await taskTool.execute(args, TEST_CONTEXT) + const result = JSON.parse(resultStr) + + //#then + expect(result).toHaveProperty("error") + expect(result.error).toBe("task_not_found") + }) + + test("returns result as JSON string", async () => { + //#given + const testId = await createTestTask("Test task") + const args = { + action: "delete" as const, + id: testId, + } + + //#when + const resultStr = await taskTool.execute(args, TEST_CONTEXT) + + //#then + expect(typeof resultStr).toBe("string") + const result = JSON.parse(resultStr) + expect(result).toHaveProperty("success") + }) + }) + + // ============================================================================ + // EDGE CASE TESTS + // ============================================================================ + + describe("edge cases", () => { + test("detects circular dependency (A depends on B, B depends on A)", async () => { + //#given + const args = { + action: "create" as const, + title: "Task A", + dependsOn: ["T-taskB"], + } + + //#when + const resultStr = await taskTool.execute(args, TEST_CONTEXT) + const result = JSON.parse(resultStr) + + //#then + // Should either prevent creation or mark as circular + expect(result).toHaveProperty("task") + }) + + test("handles task depending on non-existent ID", async () => { + //#given + const args = { + action: "create" as const, + title: "Task with missing dependency", + dependsOn: ["T-nonexistent"], + } + + //#when + const resultStr = await taskTool.execute(args, TEST_CONTEXT) + const result = JSON.parse(resultStr) + + //#then + // Should either allow or return error + expect(result).toHaveProperty("task") + }) + + test("ready filter returns true for empty dependsOn", async () => { + //#given + const args = { + action: "list" as const, + ready: true, + } + + //#when + const resultStr = await taskTool.execute(args, TEST_CONTEXT) + const result = JSON.parse(resultStr) + + //#then + const tasksWithNoDeps = result.tasks.filter((t: TaskObject) => t.dependsOn.length === 0) + expect(tasksWithNoDeps.length).toBeGreaterThanOrEqual(0) + }) + + test("ready filter includes tasks with all completed dependencies", async () => { + //#given + const args = { + action: "list" as const, + ready: true, + } + + //#when + const resultStr = await taskTool.execute(args, TEST_CONTEXT) + const result = JSON.parse(resultStr) + + //#then + expect(Array.isArray(result.tasks)).toBe(true) + }) + + test("ready filter excludes tasks with incomplete dependencies", async () => { + //#given + const args = { + action: "list" as const, + ready: true, + } + + //#when + const resultStr = await taskTool.execute(args, TEST_CONTEXT) + const result = JSON.parse(resultStr) + + //#then + expect(Array.isArray(result.tasks)).toBe(true) + }) + + test("handles empty title gracefully", async () => { + //#given + const args = { + action: "create" as const, + title: "", + } + + //#when + const resultStr = await taskTool.execute(args, TEST_CONTEXT) + const result = JSON.parse(resultStr) + + //#then + // Should either reject or handle empty title + expect(result).toBeDefined() + }) + + test("handles very long title", async () => { + //#given + const longTitle = "A".repeat(1000) + const args = { + action: "create" as const, + title: longTitle, + } + + //#when + const resultStr = await taskTool.execute(args, TEST_CONTEXT) + const result = JSON.parse(resultStr) + + //#then + expect(result).toBeDefined() + }) + + test("handles special characters in title", async () => { + //#given + const args = { + action: "create" as const, + title: "Task with special chars: !@#$%^&*()", + } + + //#when + const resultStr = await taskTool.execute(args, TEST_CONTEXT) + const result = JSON.parse(resultStr) + + //#then + expect(result).toBeDefined() + }) + + test("handles unicode characters in title", async () => { + //#given + const args = { + action: "create" as const, + title: "任務 🚀 Tâche", + } + + //#when + const resultStr = await taskTool.execute(args, TEST_CONTEXT) + const result = JSON.parse(resultStr) + + //#then + expect(result).toBeDefined() + }) + + test("preserves all TaskObject fields in round-trip", async () => { + //#given + const args = { + action: "create" as const, + title: "Test task", + description: "Test description", + dependsOn: ["T-dep1"], + parentID: "T-parent", + repoURL: "https://example.com", + } + + //#when + const resultStr = await taskTool.execute(args, TEST_CONTEXT) + const result = JSON.parse(resultStr) + + //#then + expect(result.task).toHaveProperty("id") + expect(result.task).toHaveProperty("title") + expect(result.task).toHaveProperty("description") + expect(result.task).toHaveProperty("status") + expect(result.task).toHaveProperty("dependsOn") + expect(result.task).toHaveProperty("parentID") + expect(result.task).toHaveProperty("repoURL") + expect(result.task).toHaveProperty("threadID") + }) + }) +}) diff --git a/src/tools/task/task.ts b/src/tools/task/task.ts new file mode 100644 index 000000000..5e2d7534f --- /dev/null +++ b/src/tools/task/task.ts @@ -0,0 +1,253 @@ +import { tool, type ToolDefinition } from "@opencode-ai/plugin/tool" +import { existsSync, readdirSync, unlinkSync } from "fs" +import { join } from "path" +import type { OhMyOpenCodeConfig } from "../../config/schema" +import type { + TaskObject, + TaskCreateInput, + TaskListInput, + TaskGetInput, + TaskUpdateInput, + TaskDeleteInput, +} from "./types" +import { + TaskObjectSchema, + TaskCreateInputSchema, + TaskListInputSchema, + TaskGetInputSchema, + TaskUpdateInputSchema, + TaskDeleteInputSchema, +} from "./types" +import { + getTaskDir, + readJsonSafe, + writeJsonAtomic, + acquireLock, + generateTaskId, + listTaskFiles, +} from "../../features/claude-tasks/storage" + +export function createTask(config: Partial): 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. + +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)"), + description: tool.schema.string().optional().describe("Task description"), + status: tool.schema + .enum(["open", "in_progress", "completed"]) + .optional() + .describe("Task status"), + dependsOn: tool.schema + .array(tool.schema.string()) + .optional() + .describe("Task IDs this task depends on"), + 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)"), + ready: tool.schema.boolean().optional().describe("Filter to tasks with all dependencies completed"), + limit: tool.schema.number().optional().describe("Maximum number of tasks to return"), + }, + execute: async (args, context) => { + const action = args.action as "create" | "list" | "get" | "update" | "delete" + + switch (action) { + case "create": + return handleCreate(args, config, context) + case "list": + return handleList(args, config) + case "get": + return handleGet(args, config) + case "update": + return handleUpdate(args, config) + case "delete": + return handleDelete(args, config) + default: + return JSON.stringify({ error: "invalid_action" }) + } + }, + }) +} + +async function handleCreate( + args: Record, + config: Partial, + context: { sessionID: string } +): Promise { + const validatedArgs = TaskCreateInputSchema.parse(args) + const taskDir = getTaskDir(config) + const lock = acquireLock(taskDir) + + 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, + } + + const validatedTask = TaskObjectSchema.parse(task) + writeJsonAtomic(join(taskDir, `${taskId}.json`), validatedTask) + + return JSON.stringify({ task: validatedTask }) + } finally { + lock.release() + } +} + +async function handleList( + args: Record, + config: Partial +): Promise { + const validatedArgs = TaskListInputSchema.parse(args) + const taskDir = getTaskDir(config) + + if (!existsSync(taskDir)) { + return JSON.stringify({ tasks: [] }) + } + + const files = listTaskFiles(config) + 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 tasks by default + let tasks = allTasks.filter((task) => task.status !== "completed") + + // Apply status filter if provided + if (validatedArgs.status) { + tasks = tasks.filter((task) => task.status === validatedArgs.status) + } + + // Apply parentID filter if provided + if (validatedArgs.parentID) { + 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 + } + + // All dependencies must be completed + return task.dependsOn.every((depId) => { + const depTask = allTasks.find((t) => t.id === depId) + return depTask?.status === "completed" + }) + }) + } + + // Apply limit if provided + const limit = args.limit as number | undefined + if (limit !== undefined && limit > 0) { + tasks = tasks.slice(0, limit) + } + + return JSON.stringify({ tasks }) +} + +async function handleGet( + args: Record, + config: Partial +): Promise { + const validatedArgs = TaskGetInputSchema.parse(args) + const taskDir = getTaskDir(config) + const taskPath = join(taskDir, `${validatedArgs.id}.json`) + + const task = readJsonSafe(taskPath, TaskObjectSchema) + + return JSON.stringify({ task: task ?? null }) +} + +async function handleUpdate( + args: Record, + config: Partial +): Promise { + const validatedArgs = TaskUpdateInputSchema.parse(args) + const taskDir = getTaskDir(config) + const lock = acquireLock(taskDir) + + try { + const taskPath = join(taskDir, `${validatedArgs.id}.json`) + const task = readJsonSafe(taskPath, TaskObjectSchema) + + if (!task) { + 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 + } + + const validatedTask = TaskObjectSchema.parse(task) + writeJsonAtomic(taskPath, validatedTask) + + return JSON.stringify({ task: validatedTask }) + } finally { + lock.release() + } +} + +async function handleDelete( + args: Record, + config: Partial +): Promise { + const validatedArgs = TaskDeleteInputSchema.parse(args) + const taskDir = getTaskDir(config) + const lock = acquireLock(taskDir) + + try { + const taskPath = join(taskDir, `${validatedArgs.id}.json`) + + if (!existsSync(taskPath)) { + return JSON.stringify({ error: "task_not_found" }) + } + + unlinkSync(taskPath) + + return JSON.stringify({ success: true }) + } finally { + lock.release() + } +} diff --git a/src/tools/task/types.ts b/src/tools/task/types.ts new file mode 100644 index 000000000..8062d6dfe --- /dev/null +++ b/src/tools/task/types.ts @@ -0,0 +1,61 @@ +import { z } from "zod" + +export const TaskStatusSchema = z.enum(["open", "in_progress", "completed"]) +export type TaskStatus = z.infer + +export const TaskObjectSchema = z + .object({ + id: z.string(), + title: z.string(), + description: z.string().optional(), + status: TaskStatusSchema, + dependsOn: z.array(z.string()).default([]), + repoURL: z.string().optional(), + parentID: z.string().optional(), + threadID: z.string(), + }) + .strict() + +export type TaskObject = z.infer + +// Action input schemas +export const TaskCreateInputSchema = z.object({ + title: z.string(), + description: z.string().optional(), + dependsOn: z.array(z.string()).optional(), + repoURL: z.string().optional(), + parentID: z.string().optional(), +}) + +export type TaskCreateInput = z.infer + +export const TaskListInputSchema = z.object({ + status: TaskStatusSchema.optional(), + parentID: z.string().optional(), +}) + +export type TaskListInput = z.infer + +export const TaskGetInputSchema = z.object({ + id: z.string(), +}) + +export type TaskGetInput = z.infer + +export const TaskUpdateInputSchema = z.object({ + id: z.string(), + title: z.string().optional(), + description: z.string().optional(), + status: TaskStatusSchema.optional(), + dependsOn: z.array(z.string()).optional(), + repoURL: z.string().optional(), + parentID: z.string().optional(), +}) + +export type TaskUpdateInput = z.infer + +export const TaskDeleteInputSchema = z.object({ + id: z.string(), +}) + +export type TaskDeleteInput = z.infer diff --git a/tsconfig.json b/tsconfig.json index 3a923ff4f..7964411e1 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -16,5 +16,5 @@ "types": ["bun-types"] }, "include": ["src/**/*"], - "exclude": ["node_modules", "dist"] + "exclude": ["node_modules", "dist", "**/*.test.ts", "script"] }