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>