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:
commit
368ac310a1
3
.gitignore
vendored
3
.gitignore
vendored
@ -1,5 +1,6 @@
|
||||
# Dependencies
|
||||
.sisyphus/
|
||||
.sisyphus/*
|
||||
!.sisyphus/rules/
|
||||
node_modules/
|
||||
|
||||
# Build output
|
||||
|
||||
117
.sisyphus/rules/modular-code-enforcement.md
Normal file
117
.sisyphus/rules/modular-code-enforcement.md
Normal 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>
|
||||
@ -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([
|
||||
|
||||
215
src/hooks/anthropic-effort/index.test.ts
Normal file
215
src/hooks/anthropic-effort/index.test.ts
Normal 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,
|
||||
})
|
||||
})
|
||||
})
|
||||
})
|
||||
56
src/hooks/anthropic-effort/index.ts
Normal file
56
src/hooks/anthropic-effort/index.ts
Normal 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,
|
||||
})
|
||||
},
|
||||
}
|
||||
}
|
||||
28
src/index.ts
28
src/index.ts
@ -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);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user