Merge pull request #1564 from code-yeongyu/feat/anthropic-effort-hook

feat: add anthropic-effort hook to inject effort=max for Opus 4.6
This commit is contained in:
YeonGyu-Kim 2026-02-06 21:58:05 +09:00 committed by GitHub
commit 368ac310a1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 419 additions and 1 deletions

3
.gitignore vendored
View File

@ -1,5 +1,6 @@
# Dependencies
.sisyphus/
.sisyphus/*
!.sisyphus/rules/
node_modules/
# Build output

View File

@ -0,0 +1,117 @@
---
globs: ["**/*.ts", "**/*.tsx"]
alwaysApply: false
description: "Enforces strict modular code architecture: SRP, no monolithic index.ts, 200 LOC hard limit"
---
<MANDATORY_ARCHITECTURE_RULE severity="BLOCKING" priority="HIGHEST">
# Modular Code Architecture — Zero Tolerance Policy
This rule is NON-NEGOTIABLE. Violations BLOCK all further work until resolved.
## Rule 1: index.ts is an ENTRY POINT, NOT a dumping ground
`index.ts` files MUST ONLY contain:
- Re-exports (`export { ... } from "./module"`)
- Factory function calls that compose modules
- Top-level wiring/registration (hook registration, plugin setup)
`index.ts` MUST NEVER contain:
- Business logic implementation
- Helper/utility functions
- Type definitions beyond simple re-exports
- Multiple unrelated responsibilities mixed together
**If you find mixed logic in index.ts**: Extract each responsibility into its own dedicated file BEFORE making any other changes. This is not optional.
## Rule 2: No Catch-All Files — utils.ts / service.ts are CODE SMELLS
A single `utils.ts`, `helpers.ts`, `service.ts`, or `common.ts` is a **gravity well** — every unrelated function gets tossed in, and it grows into an untestable, unreviewable blob.
**These file names are BANNED as top-level catch-alls.** Instead:
| Anti-Pattern | Refactor To |
|--------------|-------------|
| `utils.ts` with `formatDate()`, `slugify()`, `retry()` | `date-formatter.ts`, `slugify.ts`, `retry.ts` |
| `service.ts` handling auth + billing + notifications | `auth-service.ts`, `billing-service.ts`, `notification-service.ts` |
| `helpers.ts` with 15 unrelated exports | One file per logical domain |
**Design for reusability from the start.** Each module should be:
- **Independently importable** — no consumer should need to pull in unrelated code
- **Self-contained** — its dependencies are explicit, not buried in a shared grab-bag
- **Nameable by purpose** — the filename alone tells you what it does
If you catch yourself typing `utils.ts` or `service.ts`, STOP and name the file after what it actually does.
## Rule 3: Single Responsibility Principle — ABSOLUTE
Every `.ts` file MUST have exactly ONE clear, nameable responsibility.
**Self-test**: If you cannot describe the file's purpose in ONE short phrase (e.g., "parses YAML frontmatter", "matches rules against file paths"), the file does too much. Split it.
| Signal | Action |
|--------|--------|
| File has 2+ unrelated exported functions | **SPLIT NOW** — each into its own module |
| File mixes I/O with pure logic | **SPLIT NOW** — separate side effects from computation |
| File has both types and implementation | **SPLIT NOW** — types.ts + implementation.ts |
| You need to scroll to understand the file | **SPLIT NOW** — it's too large |
## Rule 4: 200 LOC Hard Limit — CODE SMELL DETECTOR
Any `.ts`/`.tsx` file exceeding **200 lines of code** (excluding prompt strings, template literals containing prompts, and `.md` content) is an **immediate code smell**.
**When you detect a file > 200 LOC**:
1. **STOP** current work
2. **Identify** the multiple responsibilities hiding in the file
3. **Extract** each responsibility into a focused module
4. **Verify** each resulting file is < 200 LOC and has a single purpose
5. **Resume** original work
Prompt-heavy files (agent definitions, skill definitions) where the bulk of content is template literal prompt text are EXEMPT from the LOC count — but their non-prompt logic must still be < 200 LOC.
### How to Count LOC
**Count these** (= actual logic):
- Import statements
- Variable/constant declarations
- Function/class/interface/type definitions
- Control flow (`if`, `for`, `while`, `switch`, `try/catch`)
- Expressions, assignments, return statements
- Closing braces `}` that belong to logic blocks
**Exclude these** (= not logic):
- Blank lines
- Comment-only lines (`//`, `/* */`, `/** */`)
- Lines inside template literals that are prompt/instruction text (e.g., the string body of `` const prompt = `...` ``)
- Lines inside multi-line strings used as documentation/prompt content
**Quick method**: Read the file → subtract blank lines, comment-only lines, and prompt string content → remaining count = LOC.
**Example**:
```typescript
// 1 import { foo } from "./foo"; ← COUNT
// 2 ← SKIP (blank)
// 3 // Helper for bar ← SKIP (comment)
// 4 export function bar(x: number) { ← COUNT
// 5 const prompt = ` ← COUNT (declaration)
// 6 You are an assistant. ← SKIP (prompt text)
// 7 Follow these rules: ← SKIP (prompt text)
// 8 `; ← COUNT (closing)
// 9 return process(prompt, x); ← COUNT
// 10 } ← COUNT
```
→ LOC = **5** (lines 1, 4, 5, 9, 10). Not 10.
When in doubt, **round up** — err on the side of splitting.
## How to Apply
When reading, writing, or editing ANY `.ts`/`.tsx` file:
1. **Check the file you're touching** — does it violate any rule above?
2. **If YES** — refactor FIRST, then proceed with your task
3. **If creating a new file** — ensure it has exactly one responsibility and stays under 200 LOC
4. **If adding code to an existing file** — verify the addition doesn't push the file past 200 LOC or add a second responsibility. If it does, extract into a new module.
</MANDATORY_ARCHITECTURE_RULE>

View File

@ -101,6 +101,7 @@ export const HookNameSchema = z.enum([
"stop-continuation-guard",
"tasks-todowrite-disabler",
"write-existing-file-guard",
"anthropic-effort",
])
export const BuiltinCommandNameSchema = z.enum([

View File

@ -0,0 +1,215 @@
import { describe, expect, it } from "bun:test"
import { createAnthropicEffortHook } from "./index"
interface ChatParamsInput {
sessionID: string
agent: { name?: string }
model: { providerID: string; modelID: string; id?: string; api?: { npm?: string } }
provider: { id: string }
message: { variant?: string }
}
interface ChatParamsOutput {
temperature?: number
topP?: number
topK?: number
options: Record<string, unknown>
}
function createMockParams(overrides: {
providerID?: string
modelID?: string
variant?: string
agentName?: string
existingOptions?: Record<string, unknown>
}): { input: ChatParamsInput; output: ChatParamsOutput } {
const providerID = overrides.providerID ?? "anthropic"
const modelID = overrides.modelID ?? "claude-opus-4-6"
const variant = "variant" in overrides ? overrides.variant : "max"
const agentName = overrides.agentName ?? "sisyphus"
const existingOptions = overrides.existingOptions ?? {}
return {
input: {
sessionID: "test-session",
agent: { name: agentName },
model: { providerID, modelID },
provider: { id: providerID },
message: { variant },
},
output: {
temperature: 0.1,
options: { ...existingOptions },
},
}
}
describe("createAnthropicEffortHook", () => {
describe("opus 4-6 with variant max", () => {
it("should inject effort max for anthropic opus-4-6 with variant max", async () => {
//#given anthropic opus-4-6 model with variant max
const hook = createAnthropicEffortHook()
const { input, output } = createMockParams({})
//#when chat.params hook is called
await hook["chat.params"](input, output)
//#then effort should be injected into options
expect(output.options.effort).toBe("max")
})
it("should inject effort max for github-copilot claude-opus-4-6", async () => {
//#given github-copilot provider with claude-opus-4-6
const hook = createAnthropicEffortHook()
const { input, output } = createMockParams({
providerID: "github-copilot",
modelID: "claude-opus-4-6",
})
//#when chat.params hook is called
await hook["chat.params"](input, output)
//#then effort should be injected (github-copilot resolves to anthropic)
expect(output.options.effort).toBe("max")
})
it("should inject effort max for opencode provider with claude-opus-4-6", async () => {
//#given opencode provider with claude-opus-4-6
const hook = createAnthropicEffortHook()
const { input, output } = createMockParams({
providerID: "opencode",
modelID: "claude-opus-4-6",
})
//#when chat.params hook is called
await hook["chat.params"](input, output)
//#then effort should be injected
expect(output.options.effort).toBe("max")
})
it("should handle normalized model ID with dots (opus-4.6)", async () => {
//#given model ID with dots instead of hyphens
const hook = createAnthropicEffortHook()
const { input, output } = createMockParams({
modelID: "claude-opus-4.6",
})
//#when chat.params hook is called
await hook["chat.params"](input, output)
//#then should normalize and inject effort
expect(output.options.effort).toBe("max")
})
})
describe("conditions NOT met - should skip", () => {
it("should NOT inject effort when variant is not max", async () => {
//#given opus-4-6 with variant high (not max)
const hook = createAnthropicEffortHook()
const { input, output } = createMockParams({ variant: "high" })
//#when chat.params hook is called
await hook["chat.params"](input, output)
//#then effort should NOT be injected
expect(output.options.effort).toBeUndefined()
})
it("should NOT inject effort when variant is undefined", async () => {
//#given opus-4-6 with no variant
const hook = createAnthropicEffortHook()
const { input, output } = createMockParams({ variant: undefined })
//#when chat.params hook is called
await hook["chat.params"](input, output)
//#then effort should NOT be injected
expect(output.options.effort).toBeUndefined()
})
it("should NOT inject effort for non-opus model", async () => {
//#given claude-sonnet-4-5 (not opus)
const hook = createAnthropicEffortHook()
const { input, output } = createMockParams({
modelID: "claude-sonnet-4-5",
})
//#when chat.params hook is called
await hook["chat.params"](input, output)
//#then effort should NOT be injected
expect(output.options.effort).toBeUndefined()
})
it("should NOT inject effort for non-anthropic provider with non-claude model", async () => {
//#given openai provider with gpt model
const hook = createAnthropicEffortHook()
const { input, output } = createMockParams({
providerID: "openai",
modelID: "gpt-5.2",
})
//#when chat.params hook is called
await hook["chat.params"](input, output)
//#then effort should NOT be injected
expect(output.options.effort).toBeUndefined()
})
it("should NOT throw when model.modelID is undefined", async () => {
//#given model with undefined modelID (runtime edge case)
const hook = createAnthropicEffortHook()
const input = {
sessionID: "test-session",
agent: { name: "sisyphus" },
model: { providerID: "anthropic", modelID: undefined as unknown as string },
provider: { id: "anthropic" },
message: { variant: "max" as const },
}
const output = { temperature: 0.1, options: {} }
//#when chat.params hook is called with undefined modelID
await hook["chat.params"](input, output)
//#then should gracefully skip without throwing
expect(output.options.effort).toBeUndefined()
})
})
describe("preserves existing options", () => {
it("should NOT overwrite existing effort if already set", async () => {
//#given options already have effort set
const hook = createAnthropicEffortHook()
const { input, output } = createMockParams({
existingOptions: { effort: "high" },
})
//#when chat.params hook is called
await hook["chat.params"](input, output)
//#then existing effort should be preserved
expect(output.options.effort).toBe("high")
})
it("should preserve other existing options when injecting effort", async () => {
//#given options with existing thinking config
const hook = createAnthropicEffortHook()
const { input, output } = createMockParams({
existingOptions: {
thinking: { type: "enabled", budgetTokens: 31999 },
},
})
//#when chat.params hook is called
await hook["chat.params"](input, output)
//#then effort should be added without affecting thinking
expect(output.options.effort).toBe("max")
expect(output.options.thinking).toEqual({
type: "enabled",
budgetTokens: 31999,
})
})
})
})

View File

@ -0,0 +1,56 @@
import { log } from "../../shared"
const OPUS_4_6_PATTERN = /claude-opus-4[-.]6/i
function normalizeModelID(modelID: string): string {
return modelID.replace(/\.(\d+)/g, "-$1")
}
function isClaudeProvider(providerID: string, modelID: string): boolean {
if (["anthropic", "opencode"].includes(providerID)) return true
if (providerID === "github-copilot" && modelID.toLowerCase().includes("claude")) return true
return false
}
function isOpus46(modelID: string): boolean {
const normalized = normalizeModelID(modelID)
return OPUS_4_6_PATTERN.test(normalized)
}
interface ChatParamsInput {
sessionID: string
agent: { name?: string }
model: { providerID: string; modelID: string }
provider: { id: string }
message: { variant?: string }
}
interface ChatParamsOutput {
temperature?: number
topP?: number
topK?: number
options: Record<string, unknown>
}
export function createAnthropicEffortHook() {
return {
"chat.params": async (
input: ChatParamsInput,
output: ChatParamsOutput
): Promise<void> => {
const { model, message } = input
if (!model?.modelID || !model?.providerID) return
if (message.variant !== "max") return
if (!isClaudeProvider(model.providerID, model.modelID)) return
if (!isOpus46(model.modelID)) return
if (output.options.effort !== undefined) return
output.options.effort = "max"
log("anthropic-effort: injected effort=max", {
sessionID: input.sessionID,
provider: model.providerID,
model: model.modelID,
})
},
}
}

View File

@ -40,6 +40,7 @@ import {
createTasksTodowriteDisablerHook,
createWriteExistingFileGuardHook,
} from "./hooks";
import { createAnthropicEffortHook } from "./hooks/anthropic-effort";
import {
contextCollector,
createContextInjectorMessagesTransformHook,
@ -294,6 +295,10 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
const taskResumeInfo = createTaskResumeInfoHook();
const anthropicEffort = isHookEnabled("anthropic-effort")
? createAnthropicEffortHook()
: null;
const tmuxSessionManager = new TmuxSessionManager(ctx, tmuxConfig);
const backgroundManager = new BackgroundManager(
@ -550,6 +555,29 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
return {
tool: filteredTools,
"chat.params": async (
input: {
sessionID: string
agent: string
model: Record<string, unknown>
provider: Record<string, unknown>
message: Record<string, unknown>
},
output: {
temperature: number
topP: number
topK: number
options: Record<string, unknown>
},
) => {
const model = input.model as { providerID: string; modelID: string }
const message = input.message as { variant?: string }
await anthropicEffort?.["chat.params"]?.(
{ ...input, agent: { name: input.agent }, model, provider: input.provider as { id: string }, message },
output,
);
},
"chat.message": async (input, output) => {
if (input.agent) {
setSessionAgent(input.sessionID, input.agent);