From da416b362b6b5fe3533bc154b35ab7a500aa2f6c Mon Sep 17 00:00:00 2001 From: justsisyphus Date: Mon, 26 Jan 2026 11:48:32 +0900 Subject: [PATCH] feat(hooks): add category-skill-reminder hook (#1123) Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode) Co-authored-by: justsisyphus Co-authored-by: Sisyphus --- src/config/schema.ts | 1 + .../category-skill-reminder/index.test.ts | 346 ++++++++++++++++++ src/hooks/category-skill-reminder/index.ts | 165 +++++++++ src/hooks/index.ts | 1 + src/index.ts | 7 + 5 files changed, 520 insertions(+) create mode 100644 src/hooks/category-skill-reminder/index.test.ts create mode 100644 src/hooks/category-skill-reminder/index.ts diff --git a/src/config/schema.ts b/src/config/schema.ts index 1d9214195..7da8b15ee 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -77,6 +77,7 @@ export const HookNameSchema = z.enum([ "thinking-block-validator", "ralph-loop", + "category-skill-reminder", "compaction-context-injector", "claude-code-hooks", diff --git a/src/hooks/category-skill-reminder/index.test.ts b/src/hooks/category-skill-reminder/index.test.ts new file mode 100644 index 000000000..ed2983618 --- /dev/null +++ b/src/hooks/category-skill-reminder/index.test.ts @@ -0,0 +1,346 @@ +import { describe, expect, test, beforeEach, afterEach, spyOn } from "bun:test" +import { createCategorySkillReminderHook } from "./index" +import { updateSessionAgent, clearSessionAgent, _resetForTesting } from "../../features/claude-code-session-state" +import * as sharedModule from "../../shared" + +describe("category-skill-reminder hook", () => { + let logCalls: Array<{ msg: string; data?: unknown }> + let logSpy: ReturnType + + beforeEach(() => { + _resetForTesting() + logCalls = [] + logSpy = spyOn(sharedModule, "log").mockImplementation((msg: string, data?: unknown) => { + logCalls.push({ msg, data }) + }) + }) + + afterEach(() => { + logSpy?.mockRestore() + }) + + function createMockPluginInput() { + return { + client: { + tui: { + showToast: async () => {}, + }, + }, + } as any + } + + describe("target agent detection", () => { + test("should inject reminder for sisyphus agent after 3 tool calls", async () => { + // #given - sisyphus agent session with multiple tool calls + const hook = createCategorySkillReminderHook(createMockPluginInput()) + const sessionID = "sisyphus-session" + updateSessionAgent(sessionID, "Sisyphus") + + const output = { title: "", output: "file content", metadata: {} } + + // #when - 3 edit tool calls are made + await hook["tool.execute.after"]({ tool: "edit", sessionID, callID: "1" }, output) + await hook["tool.execute.after"]({ tool: "edit", sessionID, callID: "2" }, output) + await hook["tool.execute.after"]({ tool: "edit", sessionID, callID: "3" }, output) + + // #then - reminder should be injected + expect(output.output).toContain("[Category+Skill Reminder]") + expect(output.output).toContain("delegate_task") + + clearSessionAgent(sessionID) + }) + + test("should inject reminder for atlas agent", async () => { + // #given - atlas agent session + const hook = createCategorySkillReminderHook(createMockPluginInput()) + const sessionID = "atlas-session" + updateSessionAgent(sessionID, "Atlas") + + const output = { title: "", output: "result", metadata: {} } + + // #when - 3 tool calls are made + await hook["tool.execute.after"]({ tool: "bash", sessionID, callID: "1" }, output) + await hook["tool.execute.after"]({ tool: "bash", sessionID, callID: "2" }, output) + await hook["tool.execute.after"]({ tool: "bash", sessionID, callID: "3" }, output) + + // #then - reminder should be injected + expect(output.output).toContain("[Category+Skill Reminder]") + + clearSessionAgent(sessionID) + }) + + test("should inject reminder for sisyphus-junior agent", async () => { + // #given - sisyphus-junior agent session + const hook = createCategorySkillReminderHook(createMockPluginInput()) + const sessionID = "junior-session" + updateSessionAgent(sessionID, "sisyphus-junior") + + const output = { title: "", output: "result", metadata: {} } + + // #when - 3 tool calls are made + await hook["tool.execute.after"]({ tool: "write", sessionID, callID: "1" }, output) + await hook["tool.execute.after"]({ tool: "write", sessionID, callID: "2" }, output) + await hook["tool.execute.after"]({ tool: "write", sessionID, callID: "3" }, output) + + // #then - reminder should be injected + expect(output.output).toContain("[Category+Skill Reminder]") + + clearSessionAgent(sessionID) + }) + + test("should NOT inject reminder for non-target agents", async () => { + // #given - librarian agent session (not a target) + const hook = createCategorySkillReminderHook(createMockPluginInput()) + const sessionID = "librarian-session" + updateSessionAgent(sessionID, "librarian") + + const output = { title: "", output: "result", metadata: {} } + + // #when - 3 tool calls are made + await hook["tool.execute.after"]({ tool: "edit", sessionID, callID: "1" }, output) + await hook["tool.execute.after"]({ tool: "edit", sessionID, callID: "2" }, output) + await hook["tool.execute.after"]({ tool: "edit", sessionID, callID: "3" }, output) + + // #then - reminder should NOT be injected + expect(output.output).not.toContain("[Category+Skill Reminder]") + + clearSessionAgent(sessionID) + }) + + test("should detect agent from input.agent when session state is empty", async () => { + // #given - no session state, agent provided in input + const hook = createCategorySkillReminderHook(createMockPluginInput()) + const sessionID = "input-agent-session" + + const output = { title: "", output: "result", metadata: {} } + + // #when - 3 tool calls with agent in input + await hook["tool.execute.after"]({ tool: "edit", sessionID, callID: "1", agent: "Sisyphus" }, output) + await hook["tool.execute.after"]({ tool: "edit", sessionID, callID: "2", agent: "Sisyphus" }, output) + await hook["tool.execute.after"]({ tool: "edit", sessionID, callID: "3", agent: "Sisyphus" }, output) + + // #then - reminder should be injected + expect(output.output).toContain("[Category+Skill Reminder]") + }) + }) + + describe("delegation tool tracking", () => { + test("should NOT inject reminder if delegate_task is used", async () => { + // #given - sisyphus agent that uses delegate_task + const hook = createCategorySkillReminderHook(createMockPluginInput()) + const sessionID = "delegation-session" + updateSessionAgent(sessionID, "Sisyphus") + + const output = { title: "", output: "result", metadata: {} } + + // #when - delegate_task is used, then more tool calls + await hook["tool.execute.after"]({ tool: "delegate_task", sessionID, callID: "1" }, output) + await hook["tool.execute.after"]({ tool: "edit", sessionID, callID: "2" }, output) + await hook["tool.execute.after"]({ tool: "edit", sessionID, callID: "3" }, output) + await hook["tool.execute.after"]({ tool: "edit", sessionID, callID: "4" }, output) + + // #then - reminder should NOT be injected (delegation was used) + expect(output.output).not.toContain("[Category+Skill Reminder]") + + clearSessionAgent(sessionID) + }) + + test("should NOT inject reminder if call_omo_agent is used", async () => { + // #given - sisyphus agent that uses call_omo_agent + const hook = createCategorySkillReminderHook(createMockPluginInput()) + const sessionID = "omo-agent-session" + updateSessionAgent(sessionID, "Sisyphus") + + const output = { title: "", output: "result", metadata: {} } + + // #when - call_omo_agent is used first + await hook["tool.execute.after"]({ tool: "call_omo_agent", sessionID, callID: "1" }, output) + await hook["tool.execute.after"]({ tool: "edit", sessionID, callID: "2" }, output) + await hook["tool.execute.after"]({ tool: "edit", sessionID, callID: "3" }, output) + await hook["tool.execute.after"]({ tool: "edit", sessionID, callID: "4" }, output) + + // #then - reminder should NOT be injected + expect(output.output).not.toContain("[Category+Skill Reminder]") + + clearSessionAgent(sessionID) + }) + + test("should NOT inject reminder if task tool is used", async () => { + // #given - sisyphus agent that uses task tool + const hook = createCategorySkillReminderHook(createMockPluginInput()) + const sessionID = "task-session" + updateSessionAgent(sessionID, "Sisyphus") + + const output = { title: "", output: "result", metadata: {} } + + // #when - task tool is used + await hook["tool.execute.after"]({ tool: "task", sessionID, callID: "1" }, output) + await hook["tool.execute.after"]({ tool: "edit", sessionID, callID: "2" }, output) + await hook["tool.execute.after"]({ tool: "edit", sessionID, callID: "3" }, output) + await hook["tool.execute.after"]({ tool: "edit", sessionID, callID: "4" }, output) + + // #then - reminder should NOT be injected + expect(output.output).not.toContain("[Category+Skill Reminder]") + + clearSessionAgent(sessionID) + }) + }) + + describe("tool call counting", () => { + test("should NOT inject reminder before 3 tool calls", async () => { + // #given - sisyphus agent with only 2 tool calls + const hook = createCategorySkillReminderHook(createMockPluginInput()) + const sessionID = "few-calls-session" + updateSessionAgent(sessionID, "Sisyphus") + + const output = { title: "", output: "result", metadata: {} } + + // #when - only 2 tool calls are made + await hook["tool.execute.after"]({ tool: "edit", sessionID, callID: "1" }, output) + await hook["tool.execute.after"]({ tool: "edit", sessionID, callID: "2" }, output) + + // #then - reminder should NOT be injected yet + expect(output.output).not.toContain("[Category+Skill Reminder]") + + clearSessionAgent(sessionID) + }) + + test("should only inject reminder once per session", async () => { + // #given - sisyphus agent session + const hook = createCategorySkillReminderHook(createMockPluginInput()) + const sessionID = "once-session" + updateSessionAgent(sessionID, "Sisyphus") + + const output1 = { title: "", output: "result1", metadata: {} } + const output2 = { title: "", output: "result2", metadata: {} } + + // #when - 6 tool calls are made (should trigger at 3, not again at 6) + await hook["tool.execute.after"]({ tool: "edit", sessionID, callID: "1" }, output1) + await hook["tool.execute.after"]({ tool: "edit", sessionID, callID: "2" }, output1) + await hook["tool.execute.after"]({ tool: "edit", sessionID, callID: "3" }, output1) + await hook["tool.execute.after"]({ tool: "edit", sessionID, callID: "4" }, output2) + await hook["tool.execute.after"]({ tool: "edit", sessionID, callID: "5" }, output2) + await hook["tool.execute.after"]({ tool: "edit", sessionID, callID: "6" }, output2) + + // #then - reminder should be in output1 but not output2 + expect(output1.output).toContain("[Category+Skill Reminder]") + expect(output2.output).not.toContain("[Category+Skill Reminder]") + + clearSessionAgent(sessionID) + }) + + test("should only count delegatable work tools", async () => { + // #given - sisyphus agent with mixed tool calls + const hook = createCategorySkillReminderHook(createMockPluginInput()) + const sessionID = "mixed-tools-session" + updateSessionAgent(sessionID, "Sisyphus") + + const output = { title: "", output: "result", metadata: {} } + + // #when - non-delegatable tools are called (should not count) + await hook["tool.execute.after"]({ tool: "lsp_goto_definition", sessionID, callID: "1" }, output) + await hook["tool.execute.after"]({ tool: "lsp_find_references", sessionID, callID: "2" }, output) + await hook["tool.execute.after"]({ tool: "lsp_symbols", sessionID, callID: "3" }, output) + + // #then - reminder should NOT be injected (LSP tools don't count) + expect(output.output).not.toContain("[Category+Skill Reminder]") + + clearSessionAgent(sessionID) + }) + }) + + describe("event handling", () => { + test("should reset state on session.deleted event", async () => { + // #given - sisyphus agent with reminder already shown + const hook = createCategorySkillReminderHook(createMockPluginInput()) + const sessionID = "delete-session" + updateSessionAgent(sessionID, "Sisyphus") + + const output1 = { title: "", output: "result1", metadata: {} } + await hook["tool.execute.after"]({ tool: "edit", sessionID, callID: "1" }, output1) + await hook["tool.execute.after"]({ tool: "edit", sessionID, callID: "2" }, output1) + await hook["tool.execute.after"]({ tool: "edit", sessionID, callID: "3" }, output1) + expect(output1.output).toContain("[Category+Skill Reminder]") + + // #when - session is deleted and new session starts + await hook.event({ event: { type: "session.deleted", properties: { info: { id: sessionID } } } }) + + const output2 = { title: "", output: "result2", metadata: {} } + await hook["tool.execute.after"]({ tool: "edit", sessionID, callID: "4" }, output2) + await hook["tool.execute.after"]({ tool: "edit", sessionID, callID: "5" }, output2) + await hook["tool.execute.after"]({ tool: "edit", sessionID, callID: "6" }, output2) + + // #then - reminder should be shown again (state was reset) + expect(output2.output).toContain("[Category+Skill Reminder]") + + clearSessionAgent(sessionID) + }) + + test("should reset state on session.compacted event", async () => { + // #given - sisyphus agent with reminder already shown + const hook = createCategorySkillReminderHook(createMockPluginInput()) + const sessionID = "compact-session" + updateSessionAgent(sessionID, "Sisyphus") + + const output1 = { title: "", output: "result1", metadata: {} } + await hook["tool.execute.after"]({ tool: "edit", sessionID, callID: "1" }, output1) + await hook["tool.execute.after"]({ tool: "edit", sessionID, callID: "2" }, output1) + await hook["tool.execute.after"]({ tool: "edit", sessionID, callID: "3" }, output1) + expect(output1.output).toContain("[Category+Skill Reminder]") + + // #when - session is compacted + await hook.event({ event: { type: "session.compacted", properties: { sessionID } } }) + + const output2 = { title: "", output: "result2", metadata: {} } + await hook["tool.execute.after"]({ tool: "edit", sessionID, callID: "4" }, output2) + await hook["tool.execute.after"]({ tool: "edit", sessionID, callID: "5" }, output2) + await hook["tool.execute.after"]({ tool: "edit", sessionID, callID: "6" }, output2) + + // #then - reminder should be shown again (state was reset) + expect(output2.output).toContain("[Category+Skill Reminder]") + + clearSessionAgent(sessionID) + }) + }) + + describe("case insensitivity", () => { + test("should handle tool names case-insensitively", async () => { + // #given - sisyphus agent with mixed case tool names + const hook = createCategorySkillReminderHook(createMockPluginInput()) + const sessionID = "case-session" + updateSessionAgent(sessionID, "Sisyphus") + + const output = { title: "", output: "result", metadata: {} } + + // #when - tool calls with different cases + await hook["tool.execute.after"]({ tool: "EDIT", sessionID, callID: "1" }, output) + await hook["tool.execute.after"]({ tool: "Edit", sessionID, callID: "2" }, output) + await hook["tool.execute.after"]({ tool: "edit", sessionID, callID: "3" }, output) + + // #then - reminder should be injected (all counted) + expect(output.output).toContain("[Category+Skill Reminder]") + + clearSessionAgent(sessionID) + }) + + test("should handle delegation tool names case-insensitively", async () => { + // #given - sisyphus agent using DELEGATE_TASK in uppercase + const hook = createCategorySkillReminderHook(createMockPluginInput()) + const sessionID = "case-delegate-session" + updateSessionAgent(sessionID, "Sisyphus") + + const output = { title: "", output: "result", metadata: {} } + + // #when - DELEGATE_TASK in uppercase is used + await hook["tool.execute.after"]({ tool: "DELEGATE_TASK", sessionID, callID: "1" }, output) + await hook["tool.execute.after"]({ tool: "edit", sessionID, callID: "2" }, output) + await hook["tool.execute.after"]({ tool: "edit", sessionID, callID: "3" }, output) + await hook["tool.execute.after"]({ tool: "edit", sessionID, callID: "4" }, output) + + // #then - reminder should NOT be injected (delegation was detected) + expect(output.output).not.toContain("[Category+Skill Reminder]") + + clearSessionAgent(sessionID) + }) + }) +}) diff --git a/src/hooks/category-skill-reminder/index.ts b/src/hooks/category-skill-reminder/index.ts new file mode 100644 index 000000000..450fd9009 --- /dev/null +++ b/src/hooks/category-skill-reminder/index.ts @@ -0,0 +1,165 @@ +import type { PluginInput } from "@opencode-ai/plugin" +import { getSessionAgent } from "../../features/claude-code-session-state" +import { log } from "../../shared" + +/** + * Target agents that should receive category+skill reminders. + * These are orchestrator agents that delegate work to specialized agents. + */ +const TARGET_AGENTS = new Set([ + "sisyphus", + "sisyphus-junior", + "atlas", +]) + +/** + * Tools that indicate the agent is doing work that could potentially be delegated. + * When these tools are used, we remind the agent about the category+skill system. + */ +const DELEGATABLE_WORK_TOOLS = new Set([ + "edit", + "write", + "bash", + "read", + "grep", + "glob", +]) + +/** + * Tools that indicate the agent is already using delegation properly. + */ +const DELEGATION_TOOLS = new Set([ + "delegate_task", + "call_omo_agent", + "task", +]) + +const REMINDER_MESSAGE = ` +[Category+Skill Reminder] + +You are an orchestrator agent. Consider whether this work should be delegated: + +**DELEGATE when:** +- UI/Frontend work → category: "visual-engineering", skills: ["frontend-ui-ux"] +- Complex logic/architecture → category: "ultrabrain" +- Quick/trivial tasks → category: "quick" +- Git operations → skills: ["git-master"] +- Browser automation → skills: ["playwright"] or ["agent-browser"] + +**DO IT YOURSELF when:** +- Gathering context/exploring codebase +- Simple edits that are part of a larger task you're coordinating +- Tasks requiring your full context understanding + +Example delegation: +\`\`\` +delegate_task( + category="visual-engineering", + load_skills=["frontend-ui-ux"], + description="Implement responsive navbar with animations", + run_in_background=true +) +\`\`\` +` + +interface ToolExecuteInput { + tool: string + sessionID: string + callID: string + agent?: string +} + +interface ToolExecuteOutput { + title: string + output: string + metadata: unknown +} + +interface SessionState { + delegationUsed: boolean + reminderShown: boolean + toolCallCount: number +} + +export function createCategorySkillReminderHook(_ctx: PluginInput) { + const sessionStates = new Map() + + function getOrCreateState(sessionID: string): SessionState { + if (!sessionStates.has(sessionID)) { + sessionStates.set(sessionID, { + delegationUsed: false, + reminderShown: false, + toolCallCount: 0, + }) + } + return sessionStates.get(sessionID)! + } + + function isTargetAgent(sessionID: string, inputAgent?: string): boolean { + const agent = getSessionAgent(sessionID) ?? inputAgent + if (!agent) return false + const agentLower = agent.toLowerCase() + return TARGET_AGENTS.has(agentLower) || + agentLower.includes("sisyphus") || + agentLower.includes("atlas") + } + + const toolExecuteAfter = async ( + input: ToolExecuteInput, + output: ToolExecuteOutput, + ) => { + const { tool, sessionID } = input + const toolLower = tool.toLowerCase() + + if (!isTargetAgent(sessionID, input.agent)) { + return + } + + const state = getOrCreateState(sessionID) + + if (DELEGATION_TOOLS.has(toolLower)) { + state.delegationUsed = true + log("[category-skill-reminder] Delegation tool used", { sessionID, tool }) + return + } + + if (!DELEGATABLE_WORK_TOOLS.has(toolLower)) { + return + } + + state.toolCallCount++ + + if (state.toolCallCount >= 3 && !state.delegationUsed && !state.reminderShown) { + output.output += REMINDER_MESSAGE + state.reminderShown = true + log("[category-skill-reminder] Reminder injected", { + sessionID, + toolCallCount: state.toolCallCount + }) + } + } + + const eventHandler = async ({ event }: { event: { type: string; properties?: unknown } }) => { + const props = event.properties as Record | undefined + + if (event.type === "session.deleted") { + const sessionInfo = props?.info as { id?: string } | undefined + if (sessionInfo?.id) { + sessionStates.delete(sessionInfo.id) + } + } + + if (event.type === "session.compacted") { + const sessionID = (props?.sessionID ?? + (props?.info as { id?: string } | undefined)?.id) as string | undefined + if (sessionID) { + sessionStates.delete(sessionID) + } + } + } + + return { + "tool.execute.after": toolExecuteAfter, + event: eventHandler, + } +} diff --git a/src/hooks/index.ts b/src/hooks/index.ts index dd38cc383..5b1ce2104 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -22,6 +22,7 @@ export { createNonInteractiveEnvHook } from "./non-interactive-env"; export { createInteractiveBashSessionHook } from "./interactive-bash-session"; export { createThinkingBlockValidatorHook } from "./thinking-block-validator"; +export { createCategorySkillReminderHook } from "./category-skill-reminder"; export { createRalphLoopHook, type RalphLoopHook } from "./ralph-loop"; export { createAutoSlashCommandHook } from "./auto-slash-command"; export { createEditErrorRecoveryHook } from "./edit-error-recovery"; diff --git a/src/index.ts b/src/index.ts index 475f62207..95c9cfae6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -23,6 +23,7 @@ import { createInteractiveBashSessionHook, createThinkingBlockValidatorHook, + createCategorySkillReminderHook, createRalphLoopHook, createAutoSlashCommandHook, createEditErrorRecoveryHook, @@ -189,6 +190,10 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => { ? createThinkingBlockValidatorHook() : null; + const categorySkillReminder = isHookEnabled("category-skill-reminder") + ? createCategorySkillReminderHook(ctx) + : null; + const ralphLoop = isHookEnabled("ralph-loop") ? createRalphLoopHook(ctx, { config: pluginConfig.ralph_loop, @@ -434,6 +439,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => { await thinkMode?.event(input); await anthropicContextWindowLimitRecovery?.event(input); await agentUsageReminder?.event(input); + await categorySkillReminder?.event(input); await interactiveBashSession?.event(input); await ralphLoop?.event(input); await atlasHook?.handler(input); @@ -601,6 +607,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => { await rulesInjector?.["tool.execute.after"](input, output); await emptyTaskResponseDetector?.["tool.execute.after"](input, output); await agentUsageReminder?.["tool.execute.after"](input, output); + await categorySkillReminder?.["tool.execute.after"](input, output); await interactiveBashSession?.["tool.execute.after"](input, output); await editErrorRecovery?.["tool.execute.after"](input, output); await delegateTaskRetry?.["tool.execute.after"](input, output);