feat(task-system): add experimental task system with Claude Code spec alignment (#1415)

* feat(hooks): add tasks-todowrite-disabler hook to block TodoRead/TodoWrite

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

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

* feat(task-tools): add parallel execution guidance to descriptions

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

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

* refactor(index): migrate task system to experimental.task_system flag

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

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

* docs: update AGENTS.md for experimental task system

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

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

* fix(task-tests): align test field names with Claude Code spec (subject, blockedBy, addBlockedBy)

* fix: address Cubic review feedback

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

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

* fix: add optional chaining for tasksTodowriteDisabler null check

---------

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
This commit is contained in:
YeonGyu-Kim
2026-02-03 12:11:23 +09:00
committed by GitHub
parent 6b8294b7e5
commit 9ccca7a97f
15 changed files with 316 additions and 75 deletions
+1
View File
@@ -37,3 +37,4 @@ export { createStopContinuationGuardHook, type StopContinuationGuard } from "./s
export { createCompactionContextInjector, type SummarizeContext } from "./compaction-context-injector";
export { createUnstableAgentBabysitterHook } from "./unstable-agent-babysitter";
export { createPreemptiveCompactionHook } from "./preemptive-compaction";
export { createTasksTodowriteDisablerHook } from "./tasks-todowrite-disabler";
@@ -0,0 +1,10 @@
export const HOOK_NAME = "tasks-todowrite-disabler"
export const BLOCKED_TOOLS = ["TodoWrite", "TodoRead"]
export const REPLACEMENT_MESSAGE = `TodoRead/TodoWrite are disabled because experimental.task_system is enabled.
Use the new task tools instead:
- TaskCreate: Create new tasks with auto-generated IDs
- TaskUpdate: Update task status, add dependencies
- TaskList: List active tasks with dependency info
- TaskGet: Get full task details
IMPORTANT: 1 task = 1 delegate_task. Maximize parallel execution by running independent tasks concurrently.`
@@ -0,0 +1,137 @@
import { describe, expect, test } from "bun:test"
const { createTasksTodowriteDisablerHook } = await import("./index")
describe("tasks-todowrite-disabler", () => {
describe("when experimental.task_system is enabled", () => {
test("should block TodoWrite tool", async () => {
// given
const hook = createTasksTodowriteDisablerHook({ experimental: { task_system: true } })
const input = {
tool: "TodoWrite",
sessionID: "test-session",
callID: "call-1",
}
const output = {
args: {},
}
// when / then
await expect(
hook["tool.execute.before"](input, output)
).rejects.toThrow("TodoRead/TodoWrite are disabled")
})
test("should block TodoRead tool", async () => {
// given
const hook = createTasksTodowriteDisablerHook({ experimental: { task_system: true } })
const input = {
tool: "TodoRead",
sessionID: "test-session",
callID: "call-1",
}
const output = {
args: {},
}
// when / then
await expect(
hook["tool.execute.before"](input, output)
).rejects.toThrow("TodoRead/TodoWrite are disabled")
})
test("should not block other tools", async () => {
// given
const hook = createTasksTodowriteDisablerHook({ experimental: { task_system: true } })
const input = {
tool: "Read",
sessionID: "test-session",
callID: "call-1",
}
const output = {
args: {},
}
// when / then
await expect(
hook["tool.execute.before"](input, output)
).resolves.toBeUndefined()
})
})
describe("when experimental.task_system is disabled or undefined", () => {
test("should not block TodoWrite when flag is false", async () => {
// given
const hook = createTasksTodowriteDisablerHook({ experimental: { task_system: false } })
const input = {
tool: "TodoWrite",
sessionID: "test-session",
callID: "call-1",
}
const output = {
args: {},
}
// when / then
await expect(
hook["tool.execute.before"](input, output)
).resolves.toBeUndefined()
})
test("should not block TodoWrite when experimental is undefined", async () => {
// given
const hook = createTasksTodowriteDisablerHook({})
const input = {
tool: "TodoWrite",
sessionID: "test-session",
callID: "call-1",
}
const output = {
args: {},
}
// when / then
await expect(
hook["tool.execute.before"](input, output)
).resolves.toBeUndefined()
})
test("should not block TodoRead when flag is false", async () => {
// given
const hook = createTasksTodowriteDisablerHook({ experimental: { task_system: false } })
const input = {
tool: "TodoRead",
sessionID: "test-session",
callID: "call-1",
}
const output = {
args: {},
}
// when / then
await expect(
hook["tool.execute.before"](input, output)
).resolves.toBeUndefined()
})
})
describe("error message content", () => {
test("should include replacement message with task tools info", async () => {
// given
const hook = createTasksTodowriteDisablerHook({ experimental: { task_system: true } })
const input = {
tool: "TodoWrite",
sessionID: "test-session",
callID: "call-1",
}
const output = {
args: {},
}
// when / then
await expect(
hook["tool.execute.before"](input, output)
).rejects.toThrow(/TaskCreate|TaskUpdate|TaskList|TaskGet/)
})
})
})
@@ -0,0 +1,29 @@
import { BLOCKED_TOOLS, REPLACEMENT_MESSAGE } from "./constants";
export interface TasksTodowriteDisablerConfig {
experimental?: {
task_system?: boolean;
};
}
export function createTasksTodowriteDisablerHook(
config: TasksTodowriteDisablerConfig,
) {
const isTaskSystemEnabled = config.experimental?.task_system ?? false;
return {
"tool.execute.before": async (
input: { tool: string; sessionID: string; callID: string },
output: { args: Record<string, unknown> },
) => {
if (!isTaskSystemEnabled) {
return;
}
const toolName = input.tool as string;
if (BLOCKED_TOOLS.some((blocked) => blocked.toLowerCase() === toolName.toLowerCase())) {
throw new Error(REPLACEMENT_MESSAGE);
}
},
};
}