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
|
# Dependencies
|
||||||
.sisyphus/
|
.sisyphus/*
|
||||||
|
!.sisyphus/rules/
|
||||||
node_modules/
|
node_modules/
|
||||||
|
|
||||||
# Build output
|
# 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",
|
"stop-continuation-guard",
|
||||||
"tasks-todowrite-disabler",
|
"tasks-todowrite-disabler",
|
||||||
"write-existing-file-guard",
|
"write-existing-file-guard",
|
||||||
|
"anthropic-effort",
|
||||||
])
|
])
|
||||||
|
|
||||||
export const BuiltinCommandNameSchema = z.enum([
|
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,
|
createTasksTodowriteDisablerHook,
|
||||||
createWriteExistingFileGuardHook,
|
createWriteExistingFileGuardHook,
|
||||||
} from "./hooks";
|
} from "./hooks";
|
||||||
|
import { createAnthropicEffortHook } from "./hooks/anthropic-effort";
|
||||||
import {
|
import {
|
||||||
contextCollector,
|
contextCollector,
|
||||||
createContextInjectorMessagesTransformHook,
|
createContextInjectorMessagesTransformHook,
|
||||||
@ -294,6 +295,10 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
|||||||
|
|
||||||
const taskResumeInfo = createTaskResumeInfoHook();
|
const taskResumeInfo = createTaskResumeInfoHook();
|
||||||
|
|
||||||
|
const anthropicEffort = isHookEnabled("anthropic-effort")
|
||||||
|
? createAnthropicEffortHook()
|
||||||
|
: null;
|
||||||
|
|
||||||
const tmuxSessionManager = new TmuxSessionManager(ctx, tmuxConfig);
|
const tmuxSessionManager = new TmuxSessionManager(ctx, tmuxConfig);
|
||||||
|
|
||||||
const backgroundManager = new BackgroundManager(
|
const backgroundManager = new BackgroundManager(
|
||||||
@ -550,6 +555,29 @@ const OhMyOpenCodePlugin: Plugin = async (ctx) => {
|
|||||||
return {
|
return {
|
||||||
tool: filteredTools,
|
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) => {
|
"chat.message": async (input, output) => {
|
||||||
if (input.agent) {
|
if (input.agent) {
|
||||||
setSessionAgent(input.sessionID, input.agent);
|
setSessionAgent(input.sessionID, input.agent);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user