diff --git a/src/config/schema/hooks.ts b/src/config/schema/hooks.ts index bb5f6bdb0b..48626b59d3 100644 --- a/src/config/schema/hooks.ts +++ b/src/config/schema/hooks.ts @@ -46,6 +46,8 @@ export const HookNameSchema = z.enum([ "tasks-todowrite-disabler", "write-existing-file-guard", "anthropic-effort", + "loop-detector", + "definition-gates", ]) export type HookName = z.infer diff --git a/src/hooks/definition-gates/index.test.ts b/src/hooks/definition-gates/index.test.ts new file mode 100644 index 0000000000..6cf956229e --- /dev/null +++ b/src/hooks/definition-gates/index.test.ts @@ -0,0 +1,212 @@ +import { describe, expect, test } from "bun:test" +import { + checkDefinitionOfReady, + checkDefinitionOfDone, + type TaskContext, + type ReadinessResult, + type CompletenessResult, +} from "./index" + +describe("definition-gates", () => { + describe("checkDefinitionOfReady", () => { + test("passes when all criteria met", () => { + //#given + const context: TaskContext = { + goal: "Add login button to header", + filesIdentified: ["/src/components/Header.tsx"], + testCriteria: "Button visible and clickable", + dependenciesMapped: true, + hasAmbiguity: false, + } + + //#when + const result = checkDefinitionOfReady(context) + + //#then + expect(result.ready).toBe(true) + expect(result.missingCriteria).toHaveLength(0) + }) + + test("fails when goal is missing", () => { + //#given + const context: TaskContext = { + goal: "", + filesIdentified: ["/src/components/Header.tsx"], + testCriteria: "Button visible", + dependenciesMapped: true, + hasAmbiguity: false, + } + + //#when + const result = checkDefinitionOfReady(context) + + //#then + expect(result.ready).toBe(false) + expect(result.missingCriteria).toContain("goal_is_atomic") + }) + + test("fails when files not identified", () => { + //#given + const context: TaskContext = { + goal: "Add login button", + filesIdentified: [], + testCriteria: "Button visible", + dependenciesMapped: true, + hasAmbiguity: false, + } + + //#when + const result = checkDefinitionOfReady(context) + + //#then + expect(result.ready).toBe(false) + expect(result.missingCriteria).toContain("files_identified") + }) + + test("fails when test criteria missing", () => { + //#given + const context: TaskContext = { + goal: "Add login button", + filesIdentified: ["/src/Header.tsx"], + testCriteria: "", + dependenciesMapped: true, + hasAmbiguity: false, + } + + //#when + const result = checkDefinitionOfReady(context) + + //#then + expect(result.ready).toBe(false) + expect(result.missingCriteria).toContain("test_criteria_defined") + }) + + test("fails when ambiguity exists", () => { + //#given + const context: TaskContext = { + goal: "Add login button", + filesIdentified: ["/src/Header.tsx"], + testCriteria: "Button works", + dependenciesMapped: true, + hasAmbiguity: true, + } + + //#when + const result = checkDefinitionOfReady(context) + + //#then + expect(result.ready).toBe(false) + expect(result.missingCriteria).toContain("no_ambiguity") + }) + + test("reports multiple missing criteria", () => { + //#given + const context: TaskContext = { + goal: "", + filesIdentified: [], + testCriteria: "", + dependenciesMapped: false, + hasAmbiguity: true, + } + + //#when + const result = checkDefinitionOfReady(context) + + //#then + expect(result.ready).toBe(false) + expect(result.missingCriteria.length).toBeGreaterThanOrEqual(4) + }) + }) + + describe("checkDefinitionOfDone", () => { + test("passes when all criteria met", () => { + //#given + const context = { + testsPass: true, + typesPass: true, + noForbiddenPatterns: true, + followsCodebaseStyle: true, + todoMarkedComplete: true, + } + + //#when + const result = checkDefinitionOfDone(context) + + //#then + expect(result.complete).toBe(true) + expect(result.failedCriteria).toHaveLength(0) + }) + + test("fails when tests fail", () => { + //#given + const context = { + testsPass: false, + typesPass: true, + noForbiddenPatterns: true, + followsCodebaseStyle: true, + todoMarkedComplete: true, + } + + //#when + const result = checkDefinitionOfDone(context) + + //#then + expect(result.complete).toBe(false) + expect(result.failedCriteria).toContain("tests_pass") + }) + + test("fails when types fail", () => { + //#given + const context = { + testsPass: true, + typesPass: false, + noForbiddenPatterns: true, + followsCodebaseStyle: true, + todoMarkedComplete: true, + } + + //#when + const result = checkDefinitionOfDone(context) + + //#then + expect(result.complete).toBe(false) + expect(result.failedCriteria).toContain("types_pass") + }) + + test("fails when forbidden patterns found", () => { + //#given + const context = { + testsPass: true, + typesPass: true, + noForbiddenPatterns: false, + followsCodebaseStyle: true, + todoMarkedComplete: true, + } + + //#when + const result = checkDefinitionOfDone(context) + + //#then + expect(result.complete).toBe(false) + expect(result.failedCriteria).toContain("no_forbidden_patterns") + }) + + test("reports multiple failed criteria", () => { + //#given + const context = { + testsPass: false, + typesPass: false, + noForbiddenPatterns: false, + followsCodebaseStyle: false, + todoMarkedComplete: false, + } + + //#when + const result = checkDefinitionOfDone(context) + + //#then + expect(result.complete).toBe(false) + expect(result.failedCriteria.length).toBe(5) + }) + }) +}) diff --git a/src/hooks/definition-gates/index.ts b/src/hooks/definition-gates/index.ts new file mode 100644 index 0000000000..6dbf558d20 --- /dev/null +++ b/src/hooks/definition-gates/index.ts @@ -0,0 +1,225 @@ +import type { PluginInput } from "@opencode-ai/plugin" +import { log } from "../../shared/logger" + +const HOOK_NAME = "definition-gates" + +export interface TaskContext { + goal: string + filesIdentified: string[] + testCriteria: string + dependenciesMapped: boolean + hasAmbiguity: boolean +} + +export interface CompletionContext { + testsPass: boolean + typesPass: boolean + noForbiddenPatterns: boolean + followsCodebaseStyle: boolean + todoMarkedComplete: boolean +} + +export interface ReadinessResult { + ready: boolean + missingCriteria: string[] + message: string +} + +export interface CompletenessResult { + complete: boolean + failedCriteria: string[] + message: string +} + +type ReadinessCriterion = "goal_is_atomic" | "files_identified" | "test_criteria_defined" | "dependencies_mapped" | "no_ambiguity" +type CompletenessCriterion = "tests_pass" | "types_pass" | "no_forbidden_patterns" | "follows_codebase_style" | "todo_marked_complete" + +export function checkDefinitionOfReady(context: TaskContext): ReadinessResult { + const missingCriteria: ReadinessCriterion[] = [] + + if (!context.goal || context.goal.trim().length === 0) { + missingCriteria.push("goal_is_atomic") + } + + if (!context.filesIdentified || context.filesIdentified.length === 0) { + missingCriteria.push("files_identified") + } + + if (!context.testCriteria || context.testCriteria.trim().length === 0) { + missingCriteria.push("test_criteria_defined") + } + + if (!context.dependenciesMapped) { + missingCriteria.push("dependencies_mapped") + } + + if (context.hasAmbiguity) { + missingCriteria.push("no_ambiguity") + } + + const ready = missingCriteria.length === 0 + + return { + ready, + missingCriteria, + message: ready + ? "Task meets Definition of Ready" + : `Task NOT ready. Missing: ${missingCriteria.join(", ")}`, + } +} + +export function checkDefinitionOfDone(context: CompletionContext): CompletenessResult { + const failedCriteria: CompletenessCriterion[] = [] + + if (!context.testsPass) { + failedCriteria.push("tests_pass") + } + + if (!context.typesPass) { + failedCriteria.push("types_pass") + } + + if (!context.noForbiddenPatterns) { + failedCriteria.push("no_forbidden_patterns") + } + + if (!context.followsCodebaseStyle) { + failedCriteria.push("follows_codebase_style") + } + + if (!context.todoMarkedComplete) { + failedCriteria.push("todo_marked_complete") + } + + const complete = failedCriteria.length === 0 + + return { + complete, + failedCriteria, + message: complete + ? "Task meets Definition of Done" + : `Task NOT complete. Failed: ${failedCriteria.join(", ")}`, + } +} + +function createDoRReminder(result: ReadinessResult): string { + return `[DEFINITION OF READY - NOT MET] + +Before starting this task, ensure: +${result.missingCriteria.map(c => `- [ ] ${formatCriterion(c)}`).join("\n")} + +Do NOT proceed until all criteria are satisfied.` +} + +function createDoDReminder(result: CompletenessResult): string { + return `[DEFINITION OF DONE - NOT MET] + +Before marking this task complete, ensure: +${result.failedCriteria.map(c => `- [ ] ${formatCriterion(c)}`).join("\n")} + +Verification is MANDATORY. Do NOT skip.` +} + +function formatCriterion(criterion: string): string { + const labels: Record = { + goal_is_atomic: "Goal is clear and atomic (one thing)", + files_identified: "Files to modify are identified", + test_criteria_defined: "Test criteria defined (Given-When-Then)", + dependencies_mapped: "Dependencies are mapped", + no_ambiguity: "No ambiguity remains (or question asked)", + tests_pass: "Tests pass: `bun test [file.test.ts]`", + types_pass: "Types pass: `lsp_diagnostics` clean", + no_forbidden_patterns: "No forbidden patterns (as any, @ts-ignore)", + follows_codebase_style: "Follows existing codebase style", + todo_marked_complete: "Todo item marked completed", + } + return labels[criterion] ?? criterion +} + +function extractTaskContextFromPrompt(prompt: string): Partial { + const hasGoal = prompt.length > 10 + const hasFiles = /\.(ts|js|tsx|jsx|md|json)/.test(prompt) + const hasTestCriteria = /(test|expect|should|must|verify)/i.test(prompt) + const hasAmbiguity = /\b(maybe|perhaps|possibly|unclear)\b/i.test(prompt) || + /\bor\b/.test(prompt.replace(/\b(and\/or|either|error|for|or\s+not)\b/gi, "")) + const hasDependencyMention = /(depends?\s+on|after|requires?|blocks?|prerequisite|first\s+need)/i.test(prompt) + const dependenciesMapped = !hasDependencyMention || /(already|done|complete|identified)/i.test(prompt) + + return { + goal: hasGoal ? prompt.slice(0, 100) : "", + filesIdentified: hasFiles ? ["detected"] : [], + testCriteria: hasTestCriteria ? "detected" : "", + dependenciesMapped, + hasAmbiguity, + } +} + +export interface DefinitionGatesHook { + "tool.execute.before": ( + input: { tool: string; sessionID: string; callID: string }, + output: { args: Record; output?: string } + ) => Promise<{ args: Record; output?: string }> +} + +export function createDefinitionGatesHook(_ctx: PluginInput): DefinitionGatesHook { + return { + "tool.execute.before": async ( + input: { tool: string; sessionID: string; callID: string }, + output: { args: Record; output?: string } + ): Promise<{ args: Record; output?: string }> => { + const toolName = input.tool + + if (toolName === "mcp_delegate_task" || toolName === "delegate_task") { + const toolInput = output.args as Record + const prompt = (toolInput.prompt as string) ?? "" + + const partialContext = extractTaskContextFromPrompt(prompt) + const context: TaskContext = { + goal: partialContext.goal ?? "", + filesIdentified: partialContext.filesIdentified ?? [], + testCriteria: partialContext.testCriteria ?? "", + dependenciesMapped: partialContext.dependenciesMapped ?? false, + hasAmbiguity: partialContext.hasAmbiguity ?? false, + } + + const result = checkDefinitionOfReady(context) + + if (!result.ready) { + log(`[${HOOK_NAME}] DoR not met for delegation`, { + missingCriteria: result.missingCriteria, + }) + + const reminder = createDoRReminder(result) + output.output = output.output ? `${output.output}\n\n${reminder}` : reminder + } + } + + if (toolName === "mcp_todowrite" || toolName === "todowrite") { + const toolInput = output.args as Record + const todos = toolInput.todos as Array<{ status?: string }> | undefined + + const hasCompletingTodo = todos?.some(t => t.status === "completed") + + if (hasCompletingTodo) { + log(`[${HOOK_NAME}] DoD reminder for todo completion`) + + const reminder = `[DEFINITION OF DONE REMINDER] + +Before marking tasks complete, verify: +- [ ] Tests pass +- [ ] Types pass (lsp_diagnostics clean) +- [ ] No forbidden patterns +- [ ] Follows codebase style + +Have you verified all criteria?` + + output.output = output.output ? `${output.output}\n\n${reminder}` : reminder + } + } + + return output + }, + } +} + +export default createDefinitionGatesHook diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 9544752778..326c38cea8 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -44,3 +44,5 @@ export { createUnstableAgentBabysitterHook } from "./unstable-agent-babysitter"; export { createPreemptiveCompactionHook } from "./preemptive-compaction"; export { createTasksTodowriteDisablerHook } from "./tasks-todowrite-disabler"; export { createWriteExistingFileGuardHook } from "./write-existing-file-guard"; +export { createLoopDetectorHook } from "./loop-detector"; +export { createDefinitionGatesHook } from "./definition-gates"; diff --git a/src/hooks/loop-detector/index.test.ts b/src/hooks/loop-detector/index.test.ts new file mode 100644 index 0000000000..1f7813d534 --- /dev/null +++ b/src/hooks/loop-detector/index.test.ts @@ -0,0 +1,130 @@ +import { describe, expect, test, beforeEach } from "bun:test" +import { + detectLoop, + type LoopDetection, + type ToolCallRecord, + LOOP_THRESHOLDS, +} from "./index" + +describe("loop-detector", () => { + describe("detectLoop", () => { + test("returns null when no loop detected", () => { + //#given + const history: ToolCallRecord[] = [ + { tool: "read", args: { path: "/a.ts" }, timestamp: 1 }, + { tool: "read", args: { path: "/b.ts" }, timestamp: 2 }, + { tool: "edit", args: { path: "/c.ts" }, timestamp: 3 }, + ] + + //#when + const result = detectLoop(history) + + //#then + expect(result).toBeNull() + }) + + test("detects repeated identical tool calls", () => { + //#given + const history: ToolCallRecord[] = [ + { tool: "read", args: { path: "/a.ts" }, timestamp: 1 }, + { tool: "read", args: { path: "/a.ts" }, timestamp: 2 }, + { tool: "read", args: { path: "/a.ts" }, timestamp: 3 }, + ] + + //#when + const result = detectLoop(history) + + //#then + expect(result).not.toBeNull() + expect(result?.type).toBe("repeated_call") + expect(result?.count).toBe(3) + }) + + test("detects error loop pattern", () => { + //#given + const history: ToolCallRecord[] = [ + { tool: "edit", args: { path: "/a.ts" }, timestamp: 1, error: "oldString not found" }, + { tool: "edit", args: { path: "/a.ts" }, timestamp: 2, error: "oldString not found" }, + ] + + //#when + const result = detectLoop(history) + + //#then + expect(result).not.toBeNull() + expect(result?.type).toBe("error_loop") + expect(result?.pattern).toContain("oldString not found") + }) + + test("detects alternating tool pattern (ping-pong loop)", () => { + //#given + const history: ToolCallRecord[] = [ + { tool: "read", args: { path: "/a.ts", line: 1 }, timestamp: 1 }, + { tool: "edit", args: { path: "/a.ts", old: "x" }, timestamp: 2 }, + { tool: "read", args: { path: "/a.ts", line: 2 }, timestamp: 3 }, + { tool: "edit", args: { path: "/a.ts", old: "y" }, timestamp: 4 }, + { tool: "read", args: { path: "/a.ts", line: 3 }, timestamp: 5 }, + { tool: "edit", args: { path: "/a.ts", old: "z" }, timestamp: 6 }, + ] + + //#when + const result = detectLoop(history) + + //#then + expect(result).not.toBeNull() + expect(result?.type).toBe("alternating_pattern") + }) + + test("respects threshold for repeated calls", () => { + //#given + const history: ToolCallRecord[] = [ + { tool: "read", args: { path: "/a.ts" }, timestamp: 1 }, + { tool: "read", args: { path: "/a.ts" }, timestamp: 2 }, + // Only 2 repeats, threshold is 3 + ] + + //#when + const result = detectLoop(history) + + //#then + expect(result).toBeNull() + }) + + test("only considers recent history window", () => { + //#given + // Create a loop pattern in old history (3 identical calls = would trigger detection) + const loopInOldHistory: ToolCallRecord[] = [ + { tool: "read", args: { path: "/looped.ts" }, timestamp: 1 }, + { tool: "read", args: { path: "/looped.ts" }, timestamp: 2 }, + { tool: "read", args: { path: "/looped.ts" }, timestamp: 3 }, + ] + // Add enough unique calls (varied tools) to push the loop outside the history window + const tools = ["glob", "grep", "bash", "write", "lsp_diagnostics"] + const fillerHistory: ToolCallRecord[] = Array.from({ length: 12 }, (_, i) => ({ + tool: tools[i % tools.length], + args: { path: `/filler-${i}.ts` }, + timestamp: 10 + i, + })) + const history = [...loopInOldHistory, ...fillerHistory] + + //#when + const result = detectLoop(history) + + //#then + // Loop in old history (outside window) should be ignored + expect(result).toBeNull() + }) + }) + + describe("LOOP_THRESHOLDS", () => { + test("has reasonable default values", () => { + //#given + #when + const thresholds = LOOP_THRESHOLDS + + //#then + expect(thresholds.sameToolCall).toBeGreaterThanOrEqual(3) + expect(thresholds.sameErrorPattern).toBeGreaterThanOrEqual(2) + expect(thresholds.historyWindow).toBeGreaterThanOrEqual(10) + }) + }) +}) diff --git a/src/hooks/loop-detector/index.ts b/src/hooks/loop-detector/index.ts new file mode 100644 index 0000000000..4f4f8a593c --- /dev/null +++ b/src/hooks/loop-detector/index.ts @@ -0,0 +1,252 @@ +import type { PluginInput } from "@opencode-ai/plugin" +import { log } from "../../shared/logger" + +const HOOK_NAME = "loop-detector" + +export const LOOP_THRESHOLDS = { + sameToolCall: 3, + sameErrorPattern: 2, + historyWindow: 10, + alternatingPatternLength: 6, +} as const + +export interface ToolCallRecord { + tool: string + args: Record + timestamp: number + error?: string +} + +export interface LoopDetection { + type: "repeated_call" | "error_loop" | "alternating_pattern" + pattern: string + count: number + recommendation: string +} + +interface SessionLoopState { + history: ToolCallRecord[] + loopDetectedAt?: number + warningCount: number +} + +const sessionStates = new Map() + +function getState(sessionID: string): SessionLoopState { + let state = sessionStates.get(sessionID) + if (!state) { + state = { history: [], warningCount: 0 } + sessionStates.set(sessionID, state) + } + return state +} + +function argsEqual(a: Record, b: Record): boolean { + const aKeys = Object.keys(a).sort() + const bKeys = Object.keys(b).sort() + if (aKeys.length !== bKeys.length) return false + for (const key of aKeys) { + if (JSON.stringify(a[key]) !== JSON.stringify(b[key])) return false + } + return true +} + +function findRepeatedCalls(history: ToolCallRecord[]): { pattern: ToolCallRecord; count: number } | null { + if (history.length < LOOP_THRESHOLDS.sameToolCall) return null + + const recent = history.slice(-LOOP_THRESHOLDS.historyWindow) + const countMap = new Map() + + for (const record of recent) { + const key = `${record.tool}:${JSON.stringify(record.args)}` + const existing = countMap.get(key) + if (existing) { + existing.count++ + } else { + countMap.set(key, { record, count: 1 }) + } + } + + for (const { record, count } of countMap.values()) { + if (count >= LOOP_THRESHOLDS.sameToolCall) { + return { pattern: record, count } + } + } + + return null +} + +function findErrorLoop(history: ToolCallRecord[]): { pattern: string; count: number } | null { + const recent = history.slice(-LOOP_THRESHOLDS.historyWindow) + const errors = recent.filter(r => r.error) + + if (errors.length < LOOP_THRESHOLDS.sameErrorPattern) return null + + const errorCounts = new Map() + for (const record of errors) { + const key = record.error ?? "" + errorCounts.set(key, (errorCounts.get(key) ?? 0) + 1) + } + + for (const [pattern, count] of errorCounts) { + if (count >= LOOP_THRESHOLDS.sameErrorPattern) { + return { pattern, count } + } + } + + return null +} + +function findAlternatingPattern(history: ToolCallRecord[]): boolean { + if (history.length < LOOP_THRESHOLDS.alternatingPatternLength) return false + + const recent = history.slice(-LOOP_THRESHOLDS.alternatingPatternLength) + const tools = recent.map(r => r.tool) + + if (tools.length < 4) return false + + const pattern = [tools[0], tools[1]] + let matches = 0 + + for (let i = 0; i < tools.length - 1; i += 2) { + if (tools[i] === pattern[0] && tools[i + 1] === pattern[1]) { + matches++ + } + } + + return matches >= 3 +} + +export function detectLoop(history: ToolCallRecord[]): LoopDetection | null { + const repeated = findRepeatedCalls(history) + if (repeated) { + return { + type: "repeated_call", + pattern: `${repeated.pattern.tool}(${JSON.stringify(repeated.pattern.args)})`, + count: repeated.count, + recommendation: "Try a different approach or file path", + } + } + + const errorLoop = findErrorLoop(history) + if (errorLoop) { + return { + type: "error_loop", + pattern: errorLoop.pattern, + count: errorLoop.count, + recommendation: "Read the file first to understand its current state", + } + } + + if (findAlternatingPattern(history)) { + return { + type: "alternating_pattern", + pattern: "read-edit-read-edit cycle", + count: LOOP_THRESHOLDS.alternatingPatternLength, + recommendation: "Stop and analyze why edits are failing", + } + } + + return null +} + +function createLoopWarning(detection: LoopDetection): string { + return `[LOOP DETECTED - ${detection.type.toUpperCase()}] + +Pattern: ${detection.pattern} +Occurrences: ${detection.count} + +STOP. You are in a loop. This wastes tokens and does not make progress. + +Recommendation: ${detection.recommendation} + +Before continuing: +1. Analyze WHY the previous attempts failed +2. Try a DIFFERENT approach +3. If stuck, ask the user for guidance` +} + +export interface LoopDetectorHook { + "tool.execute.before": ( + input: { tool: string; sessionID: string; callID: string }, + output: { args: Record; output?: string } + ) => Promise<{ args: Record; output?: string }> + event: (input: { event: { type: string; properties?: unknown } }) => Promise +} + +export function createLoopDetectorHook(_ctx: PluginInput): LoopDetectorHook { + return { + "tool.execute.before": async ( + input: { tool: string; sessionID: string; callID: string }, + output: { args: Record; output?: string } + ): Promise<{ args: Record; output?: string }> => { + const sessionID = input.sessionID + if (!sessionID) return output + + const state = getState(sessionID) + const toolName = input.tool + const toolArgs = (output.args ?? {}) as Record + + const record: ToolCallRecord = { + tool: toolName, + args: toolArgs, + timestamp: Date.now(), + } + + state.history.push(record) + + if (state.history.length > 50) { + state.history = state.history.slice(-30) + } + + const detection = detectLoop(state.history) + + if (detection) { + state.warningCount++ + log(`[${HOOK_NAME}] Loop detected`, { + sessionID, + type: detection.type, + pattern: detection.pattern, + count: detection.count, + warningCount: state.warningCount, + }) + + const warning = createLoopWarning(detection) + output.output = output.output ? `${output.output}\n\n${warning}` : warning + + if (state.warningCount >= 3) { + log(`[${HOOK_NAME}] Multiple warnings issued, consider blocking`, { sessionID }) + } + } + + return output + }, + + event: async ({ event }): Promise => { + const props = event.properties as Record | undefined + + if (event.type === "tool.execute.after") { + const sessionID = props?.sessionID as string | undefined + const error = props?.error as string | undefined + + if (sessionID && error) { + const state = getState(sessionID) + const lastRecord = state.history[state.history.length - 1] + if (lastRecord) { + lastRecord.error = error + } + } + } + + if (event.type === "session.deleted") { + const sessionInfo = props?.info as { id?: string } | undefined + if (sessionInfo?.id) { + sessionStates.delete(sessionInfo.id) + log(`[${HOOK_NAME}] Cleaned up session state`, { sessionID: sessionInfo.id }) + } + } + }, + } +} + +export default createLoopDetectorHook diff --git a/src/index.ts b/src/index.ts index 69cae8cd5e..19bccccc1b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -41,6 +41,7 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => { const managers = createManagers({ ctx, + pluginConfig, tmuxConfig, modelCacheState, diff --git a/src/plugin/event.ts b/src/plugin/event.ts index 3ab1cd41d8..c4d0860799 100644 --- a/src/plugin/event.ts +++ b/src/plugin/event.ts @@ -49,6 +49,7 @@ export function createEventHandler(args: { await Promise.resolve(hooks.stopContinuationGuard?.event?.(input)) await Promise.resolve(hooks.compactionTodoPreserver?.event?.(input)) await Promise.resolve(hooks.atlasHook?.handler?.(input)) + await Promise.resolve(hooks.loopDetector?.event?.(input)) } const recentSyntheticIdles = new Map() diff --git a/src/plugin/hooks/create-session-hooks.ts b/src/plugin/hooks/create-session-hooks.ts index 28a0ecc32f..6bb243bb57 100644 --- a/src/plugin/hooks/create-session-hooks.ts +++ b/src/plugin/hooks/create-session-hooks.ts @@ -21,6 +21,7 @@ import { createQuestionLabelTruncatorHook, createSubagentQuestionBlockerHook, createPreemptiveCompactionHook, + createLoopDetectorHook, } from "../../hooks" import { createAnthropicEffortHook } from "../../hooks/anthropic-effort" import { @@ -52,6 +53,7 @@ export type SessionHooks = { subagentQuestionBlocker: ReturnType taskResumeInfo: ReturnType anthropicEffort: ReturnType | null + loopDetector: ReturnType | null } export function createSessionHooks(args: { @@ -156,6 +158,10 @@ export function createSessionHooks(args: { ? safeHook("anthropic-effort", () => createAnthropicEffortHook()) : null + const loopDetector = isHookEnabled("loop-detector") + ? safeHook("loop-detector", () => createLoopDetectorHook(ctx)) + : null + return { contextWindowMonitor, preemptiveCompaction, @@ -177,5 +183,6 @@ export function createSessionHooks(args: { subagentQuestionBlocker, taskResumeInfo, anthropicEffort, + loopDetector, } } diff --git a/src/plugin/hooks/create-tool-guard-hooks.ts b/src/plugin/hooks/create-tool-guard-hooks.ts index ba0cb7f4b4..3b91d61e77 100644 --- a/src/plugin/hooks/create-tool-guard-hooks.ts +++ b/src/plugin/hooks/create-tool-guard-hooks.ts @@ -10,6 +10,7 @@ import { createRulesInjectorHook, createTasksTodowriteDisablerHook, createWriteExistingFileGuardHook, + createDefinitionGatesHook, } from "../../hooks" import { getOpenCodeVersion, @@ -28,6 +29,7 @@ export type ToolGuardHooks = { rulesInjector: ReturnType | null tasksTodowriteDisabler: ReturnType | null writeExistingFileGuard: ReturnType | null + definitionGates: ReturnType | null } export function createToolGuardHooks(args: { @@ -85,6 +87,10 @@ export function createToolGuardHooks(args: { ? safeHook("write-existing-file-guard", () => createWriteExistingFileGuardHook(ctx)) : null + const definitionGates = isHookEnabled("definition-gates") + ? safeHook("definition-gates", () => createDefinitionGatesHook(ctx)) + : null + return { commentChecker, toolOutputTruncator, @@ -94,5 +100,6 @@ export function createToolGuardHooks(args: { rulesInjector, tasksTodowriteDisabler, writeExistingFileGuard, + definitionGates, } } diff --git a/src/plugin/tool-execute-before.ts b/src/plugin/tool-execute-before.ts index c7fefb0a6b..3722db8273 100644 --- a/src/plugin/tool-execute-before.ts +++ b/src/plugin/tool-execute-before.ts @@ -29,6 +29,8 @@ export function createToolExecuteBeforeHandler(args: { await hooks.prometheusMdOnly?.["tool.execute.before"]?.(input, output) await hooks.sisyphusJuniorNotepad?.["tool.execute.before"]?.(input, output) await hooks.atlasHook?.["tool.execute.before"]?.(input, output) + await hooks.loopDetector?.["tool.execute.before"]?.(input, output) + await hooks.definitionGates?.["tool.execute.before"]?.(input, output) if (input.tool === "task") { const argsObject = output.args