feat(hooks): add todo-description-override hook to enforce atomic todo format

Override TodoWrite description via tool.definition hook to require
WHERE/WHY/HOW/RESULT in each todo title and enforce 1-3 tool call
granularity.
This commit is contained in:
YeonGyu-Kim
2026-03-18 11:42:57 +09:00
parent 1d5652dfa9
commit 55ac653eaa
8 changed files with 96 additions and 0 deletions

View File

@@ -51,6 +51,7 @@ export const HookNameSchema = z.enum([
"anthropic-effort",
"hashline-read-enhancer",
"read-image-resizer",
"todo-description-override",
])
export type HookName = z.infer<typeof HookNameSchema>

View File

@@ -52,3 +52,4 @@ export { createWriteExistingFileGuardHook } from "./write-existing-file-guard";
export { createHashlineReadEnhancerHook } from "./hashline-read-enhancer";
export { createJsonErrorRecoveryHook, JSON_ERROR_TOOL_EXCLUDE_LIST, JSON_ERROR_PATTERNS, JSON_ERROR_REMINDER } from "./json-error-recovery";
export { createReadImageResizerHook } from "./read-image-resizer"
export { createTodoDescriptionOverrideHook } from "./todo-description-override"

View File

@@ -0,0 +1,28 @@
export const TODOWRITE_DESCRIPTION = `Use this tool to create and manage a structured task list for tracking progress on multi-step work.
## Todo Format (MANDATORY)
Each todo title MUST encode four elements: WHERE, WHY, HOW, and EXPECTED RESULT.
Format: "[WHERE] [HOW] to [WHY] — expect [RESULT]"
GOOD:
- "src/utils/validation.ts: Add validateEmail() for input sanitization — returns boolean"
- "UserService.create(): Call validateEmail() before DB insert — rejects invalid emails with 400"
- "validation.test.ts: Add test for missing @ sign — expect validateEmail('foo') to return false"
BAD:
- "Implement email validation" (where? how? what result?)
- "Add dark mode" (this is a feature, not a todo)
- "Fix auth" (what file? what changes? what's expected?)
## Granularity Rules
Each todo MUST be a single atomic action completable in 1-3 tool calls. If it needs more, split it.
**Size test**: Can you complete this todo by editing one file or running one command? If not, it's too big.
## Task Management
- One in_progress at a time. Complete it before starting the next.
- Mark completed immediately after finishing each item.
- Skip this tool for single trivial tasks (one-step, obvious action).`

View File

@@ -0,0 +1,14 @@
import { TODOWRITE_DESCRIPTION } from "./description"
export function createTodoDescriptionOverrideHook() {
return {
"tool.definition": async (
input: { toolID: string },
output: { description: string; parameters: unknown },
) => {
if (input.toolID === "todowrite") {
output.description = TODOWRITE_DESCRIPTION
}
},
}
}

View File

@@ -0,0 +1,40 @@
import { describe, it, expect } from "bun:test"
import { createTodoDescriptionOverrideHook } from "./hook"
import { TODOWRITE_DESCRIPTION } from "./description"
describe("createTodoDescriptionOverrideHook", () => {
describe("#given hook is created", () => {
describe("#when tool.definition is called with todowrite", () => {
it("#then should override the description", async () => {
const hook = createTodoDescriptionOverrideHook()
const output = { description: "original description", parameters: {} }
await hook["tool.definition"]({ toolID: "todowrite" }, output)
expect(output.description).toBe(TODOWRITE_DESCRIPTION)
})
})
describe("#when tool.definition is called with non-todowrite tool", () => {
it("#then should not modify the description", async () => {
const hook = createTodoDescriptionOverrideHook()
const output = { description: "original description", parameters: {} }
await hook["tool.definition"]({ toolID: "bash" }, output)
expect(output.description).toBe("original description")
})
})
describe("#when tool.definition is called with TodoWrite (case-insensitive)", () => {
it("#then should not override for different casing since OpenCode sends lowercase", async () => {
const hook = createTodoDescriptionOverrideHook()
const output = { description: "original description", parameters: {} }
await hook["tool.definition"]({ toolID: "TodoWrite" }, output)
expect(output.description).toBe("original description")
})
})
})
})

View File

@@ -0,0 +1 @@
export { createTodoDescriptionOverrideHook } from "./hook"

View File

@@ -71,5 +71,9 @@ export function createPluginInterface(args: {
ctx,
hooks,
}),
"tool.definition": async (input, output) => {
await hooks.todoDescriptionOverride?.["tool.definition"]?.(input, output)
},
}
}

View File

@@ -14,6 +14,7 @@ import {
createHashlineReadEnhancerHook,
createReadImageResizerHook,
createJsonErrorRecoveryHook,
createTodoDescriptionOverrideHook,
} from "../../hooks"
import {
getOpenCodeVersion,
@@ -35,6 +36,7 @@ export type ToolGuardHooks = {
hashlineReadEnhancer: ReturnType<typeof createHashlineReadEnhancerHook> | null
jsonErrorRecovery: ReturnType<typeof createJsonErrorRecoveryHook> | null
readImageResizer: ReturnType<typeof createReadImageResizerHook> | null
todoDescriptionOverride: ReturnType<typeof createTodoDescriptionOverrideHook> | null
}
export function createToolGuardHooks(args: {
@@ -111,6 +113,10 @@ export function createToolGuardHooks(args: {
? safeHook("read-image-resizer", () => createReadImageResizerHook(ctx))
: null
const todoDescriptionOverride = isHookEnabled("todo-description-override")
? safeHook("todo-description-override", () => createTodoDescriptionOverrideHook())
: null
return {
commentChecker,
toolOutputTruncator,
@@ -123,5 +129,6 @@ export function createToolGuardHooks(args: {
hashlineReadEnhancer,
jsonErrorRecovery,
readImageResizer,
todoDescriptionOverride,
}
}