Implement unified Claude Tasks system with single multi-action tool (#1356)

* chore: pin bun-types to 1.3.6

🤖 Generated with [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)

* chore: exclude test files and script from tsconfig

🤖 Generated with [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)

* refactor: remove sisyphus-swarm feature

Remove mailbox types and swarm config schema. Update docs.

🤖 Generated with [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)

* refactor: remove legacy sisyphus-tasks feature

Remove old storage and types implementation, replaced by claude-tasks.

🤖 Generated with [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)

* feat(claude-tasks): add task schema and storage utilities

- Task schema with Zod validation (pending, in_progress, completed, deleted)
- Storage utilities: getTaskDir, readJsonSafe, writeJsonAtomic, acquireLock
- Atomic writes with temp file + rename
- File-based locking with 30s stale threshold

🤖 Generated with [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)

* feat(tools/task): add task object schemas

Add Zod schemas for task CRUD operations input validation.

🤖 Generated with [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)

* feat(tools): add TaskCreate tool

Create new tasks with sequential ID generation and lock-based concurrency.

🤖 Generated with [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)

* feat(tools): add TaskGet tool

Retrieve task by ID with null-safe handling.

🤖 Generated with [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)

* feat(tools): add TaskUpdate tool with claim validation

Update tasks with status transitions and owner claim validation.

🤖 Generated with [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)

* feat(tools): add TaskList tool and exports

- TaskList for summary view of all tasks
- Export all claude-tasks tool factories from index

🤖 Generated with [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)

* feat(hooks): add task-reminder hook

Remind agents to use task tools after 10 turns without task operations.

🤖 Generated with [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)

* feat(config): add disabled_tools setting and tasks-todowrite-disabler hook

- Add disabled_tools config option to disable specific tools by name
- Register tasks-todowrite-disabler hook name in schema

🤖 Generated with [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)

* feat(config-handler): add task_* and teammate tool permissions

Grant task_* and teammate permissions to atlas, sisyphus, prometheus, and sisyphus-junior agents.

🤖 Generated with [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)

* feat(delegate-task): add execute option for task execution

Add optional execute field with task_id and task_dir for task-based delegation.

🤖 Generated with [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)

* fix(truncator): add type guard for non-string outputs

Prevent crashes when output is not a string by adding typeof checks.

🤖 Generated with [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)

* chore: export config types and update task-resume-info

- Export SisyphusConfig and SisyphusTasksConfig types
- Add task_tool to TARGET_TOOLS list

🤖 Generated with [OhMyOpenCode](https://github.com/code-yeongyu/oh-my-opencode)

* refactor(storage): remove team namespace, use flat task directory

* feat(task): implement unified task tool with all 5 actions

* fix(hooks): update task-reminder to track unified task tool

* refactor(tools): register unified task tool, remove 4 separate tools

* chore(cleanup): remove old 4-tool task implementation

* refactor(config): use new_task_system_enabled as top-level flag

- Add new_task_system_enabled to OhMyOpenCodeConfigSchema
- Remove enabled from SisyphusTasksConfigSchema (keep storage_path, claude_code_compat)
- Update index.ts to gate on new_task_system_enabled
- Update plugin-config.ts default for config initialization
- Update test configs in task.test.ts and storage.test.ts

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>

* fix: resolve typecheck and test failures

- Add explicit ToolDefinition return type to createTask function
- Fix planDemoteConfig to use 'subagent' mode instead of 'all'

---------

Co-authored-by: justsisyphus <justsisyphus@users.noreply.github.com>
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
YeonGyu-Kim
2026-02-01 22:42:28 +09:00
committed by GitHub
parent 491df05b63
commit 8d29a1c5c7
32 changed files with 2070 additions and 684 deletions
+102
View File
@@ -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<string, unknown>
}
```
**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)
+2
View File
@@ -0,0 +1,2 @@
export * from "./types"
export * from "./storage"
+361
View File
@@ -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<OhMyOpenCodeConfig> = {}
//#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<OhMyOpenCodeConfig> = {
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<OhMyOpenCodeConfig> = {
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<OhMyOpenCodeConfig> = {
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<OhMyOpenCodeConfig> = {
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<OhMyOpenCodeConfig> = {
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)
})
})
+112
View File
@@ -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<OhMyOpenCodeConfig> = {}): 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<T>(filePath: string, schema: z.ZodType<T>): 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<OhMyOpenCodeConfig> = {}): 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
}
},
}
}
+174
View File
@@ -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)
})
})
+20
View File
@@ -0,0 +1,20 @@
import { z } from "zod"
export const TaskStatusSchema = z.enum(["pending", "in_progress", "completed", "deleted"])
export type TaskStatus = z.infer<typeof TaskStatusSchema>
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<typeof TaskSchema>
@@ -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)
})
})
@@ -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<typeof MailboxMessageSchema>
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<typeof PermissionRequestSchema>
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<typeof PermissionResponseSchema>
export const ShutdownRequestSchema = z.object({
type: z.literal("shutdown_request"),
})
export type ShutdownRequest = z.infer<typeof ShutdownRequestSchema>
export const ShutdownApprovedSchema = z.object({
type: z.literal("shutdown_approved"),
})
export type ShutdownApproved = z.infer<typeof ShutdownApprovedSchema>
export const ShutdownRejectedSchema = z.object({
type: z.literal("shutdown_rejected"),
reason: z.string().optional(),
})
export type ShutdownRejected = z.infer<typeof ShutdownRejectedSchema>
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<typeof TaskAssignmentSchema>
export const TaskCompletedSchema = z.object({
type: z.literal("task_completed"),
taskId: z.string(),
agentId: z.string(),
timestamp: z.number(),
})
export type TaskCompleted = z.infer<typeof TaskCompletedSchema>
export const IdleNotificationSchema = z.object({
type: z.literal("idle_notification"),
})
export type IdleNotification = z.infer<typeof IdleNotificationSchema>
export const JoinRequestSchema = z.object({
type: z.literal("join_request"),
agentName: z.string(),
sessionId: z.string(),
})
export type JoinRequest = z.infer<typeof JoinRequestSchema>
export const JoinApprovedSchema = z.object({
type: z.literal("join_approved"),
agentName: z.string(),
teamName: z.string(),
})
export type JoinApproved = z.infer<typeof JoinApprovedSchema>
export const JoinRejectedSchema = z.object({
type: z.literal("join_rejected"),
reason: z.string().optional(),
})
export type JoinRejected = z.infer<typeof JoinRejectedSchema>
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<typeof PlanApprovalRequestSchema>
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<typeof PlanApprovalResponseSchema>
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<typeof ModeSetRequestSchema>
export const TeamPermissionUpdateSchema = z.object({
type: z.literal("team_permission_update"),
permissions: z.record(z.string(), z.unknown()),
})
export type TeamPermissionUpdate = z.infer<typeof TeamPermissionUpdateSchema>
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<typeof ProtocolMessageSchema>
-178
View File
@@ -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)
})
})
})
-82
View File
@@ -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<OhMyOpenCodeConfig>): 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<OhMyOpenCodeConfig>): string {
return join(getTaskDir(listId, config), `${taskId}.json`)
}
export function getTeamDir(teamName: string, config: Partial<OhMyOpenCodeConfig>): 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<OhMyOpenCodeConfig>): 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<T>(filePath: string, schema: z.ZodType<T>): 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
}
}
-82
View File
@@ -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)
})
})
-41
View File
@@ -1,41 +0,0 @@
import { z } from "zod"
export const TaskStatusSchema = z.enum(["pending", "in_progress", "completed"])
export type TaskStatus = z.infer<typeof TaskStatusSchema>
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<typeof TaskSchema>
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<typeof TaskCreateInputSchema>
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<typeof TaskUpdateInputSchema>