Skip to content

Commit 368ac31

Browse files
authored
Merge pull request #1564 from code-yeongyu/feat/anthropic-effort-hook
feat: add anthropic-effort hook to inject effort=max for Opus 4.6
2 parents d209f3c + cb2169f commit 368ac31

File tree

6 files changed

+419
-1
lines changed

6 files changed

+419
-1
lines changed

.gitignore

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
# Dependencies
2-
.sisyphus/
2+
.sisyphus/*
3+
!.sisyphus/rules/
34
node_modules/
45

56
# Build output
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
---
2+
globs: ["**/*.ts", "**/*.tsx"]
3+
alwaysApply: false
4+
description: "Enforces strict modular code architecture: SRP, no monolithic index.ts, 200 LOC hard limit"
5+
---
6+
7+
<MANDATORY_ARCHITECTURE_RULE severity="BLOCKING" priority="HIGHEST">
8+
9+
# Modular Code Architecture — Zero Tolerance Policy
10+
11+
This rule is NON-NEGOTIABLE. Violations BLOCK all further work until resolved.
12+
13+
## Rule 1: index.ts is an ENTRY POINT, NOT a dumping ground
14+
15+
`index.ts` files MUST ONLY contain:
16+
- Re-exports (`export { ... } from "./module"`)
17+
- Factory function calls that compose modules
18+
- Top-level wiring/registration (hook registration, plugin setup)
19+
20+
`index.ts` MUST NEVER contain:
21+
- Business logic implementation
22+
- Helper/utility functions
23+
- Type definitions beyond simple re-exports
24+
- Multiple unrelated responsibilities mixed together
25+
26+
**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.
27+
28+
## Rule 2: No Catch-All Files — utils.ts / service.ts are CODE SMELLS
29+
30+
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.
31+
32+
**These file names are BANNED as top-level catch-alls.** Instead:
33+
34+
| Anti-Pattern | Refactor To |
35+
|--------------|-------------|
36+
| `utils.ts` with `formatDate()`, `slugify()`, `retry()` | `date-formatter.ts`, `slugify.ts`, `retry.ts` |
37+
| `service.ts` handling auth + billing + notifications | `auth-service.ts`, `billing-service.ts`, `notification-service.ts` |
38+
| `helpers.ts` with 15 unrelated exports | One file per logical domain |
39+
40+
**Design for reusability from the start.** Each module should be:
41+
- **Independently importable** — no consumer should need to pull in unrelated code
42+
- **Self-contained** — its dependencies are explicit, not buried in a shared grab-bag
43+
- **Nameable by purpose** — the filename alone tells you what it does
44+
45+
If you catch yourself typing `utils.ts` or `service.ts`, STOP and name the file after what it actually does.
46+
47+
## Rule 3: Single Responsibility Principle — ABSOLUTE
48+
49+
Every `.ts` file MUST have exactly ONE clear, nameable responsibility.
50+
51+
**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.
52+
53+
| Signal | Action |
54+
|--------|--------|
55+
| File has 2+ unrelated exported functions | **SPLIT NOW** — each into its own module |
56+
| File mixes I/O with pure logic | **SPLIT NOW** — separate side effects from computation |
57+
| File has both types and implementation | **SPLIT NOW** — types.ts + implementation.ts |
58+
| You need to scroll to understand the file | **SPLIT NOW** — it's too large |
59+
60+
## Rule 4: 200 LOC Hard Limit — CODE SMELL DETECTOR
61+
62+
Any `.ts`/`.tsx` file exceeding **200 lines of code** (excluding prompt strings, template literals containing prompts, and `.md` content) is an **immediate code smell**.
63+
64+
**When you detect a file > 200 LOC**:
65+
1. **STOP** current work
66+
2. **Identify** the multiple responsibilities hiding in the file
67+
3. **Extract** each responsibility into a focused module
68+
4. **Verify** each resulting file is < 200 LOC and has a single purpose
69+
5. **Resume** original work
70+
71+
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.
72+
73+
### How to Count LOC
74+
75+
**Count these** (= actual logic):
76+
- Import statements
77+
- Variable/constant declarations
78+
- Function/class/interface/type definitions
79+
- Control flow (`if`, `for`, `while`, `switch`, `try/catch`)
80+
- Expressions, assignments, return statements
81+
- Closing braces `}` that belong to logic blocks
82+
83+
**Exclude these** (= not logic):
84+
- Blank lines
85+
- Comment-only lines (`//`, `/* */`, `/** */`)
86+
- Lines inside template literals that are prompt/instruction text (e.g., the string body of `` const prompt = `...` ``)
87+
- Lines inside multi-line strings used as documentation/prompt content
88+
89+
**Quick method**: Read the file → subtract blank lines, comment-only lines, and prompt string content → remaining count = LOC.
90+
91+
**Example**:
92+
```typescript
93+
// 1 import { foo } from "./foo"; ← COUNT
94+
// 2 ← SKIP (blank)
95+
// 3 // Helper for bar ← SKIP (comment)
96+
// 4 export function bar(x: number) { ← COUNT
97+
// 5 const prompt = ` ← COUNT (declaration)
98+
// 6 You are an assistant. ← SKIP (prompt text)
99+
// 7 Follow these rules: ← SKIP (prompt text)
100+
// 8 `; ← COUNT (closing)
101+
// 9 return process(prompt, x); ← COUNT
102+
// 10 } ← COUNT
103+
```
104+
→ LOC = **5** (lines 1, 4, 5, 9, 10). Not 10.
105+
106+
When in doubt, **round up** — err on the side of splitting.
107+
108+
## How to Apply
109+
110+
When reading, writing, or editing ANY `.ts`/`.tsx` file:
111+
112+
1. **Check the file you're touching** — does it violate any rule above?
113+
2. **If YES** — refactor FIRST, then proceed with your task
114+
3. **If creating a new file** — ensure it has exactly one responsibility and stays under 200 LOC
115+
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.
116+
117+
</MANDATORY_ARCHITECTURE_RULE>

src/config/schema.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,7 @@ export const HookNameSchema = z.enum([
101101
"stop-continuation-guard",
102102
"tasks-todowrite-disabler",
103103
"write-existing-file-guard",
104+
"anthropic-effort",
104105
])
105106

106107
export const BuiltinCommandNameSchema = z.enum([
Lines changed: 215 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,215 @@
1+
import { describe, expect, it } from "bun:test"
2+
import { createAnthropicEffortHook } from "./index"
3+
4+
interface ChatParamsInput {
5+
sessionID: string
6+
agent: { name?: string }
7+
model: { providerID: string; modelID: string; id?: string; api?: { npm?: string } }
8+
provider: { id: string }
9+
message: { variant?: string }
10+
}
11+
12+
interface ChatParamsOutput {
13+
temperature?: number
14+
topP?: number
15+
topK?: number
16+
options: Record<string, unknown>
17+
}
18+
19+
function createMockParams(overrides: {
20+
providerID?: string
21+
modelID?: string
22+
variant?: string
23+
agentName?: string
24+
existingOptions?: Record<string, unknown>
25+
}): { input: ChatParamsInput; output: ChatParamsOutput } {
26+
const providerID = overrides.providerID ?? "anthropic"
27+
const modelID = overrides.modelID ?? "claude-opus-4-6"
28+
const variant = "variant" in overrides ? overrides.variant : "max"
29+
const agentName = overrides.agentName ?? "sisyphus"
30+
const existingOptions = overrides.existingOptions ?? {}
31+
32+
return {
33+
input: {
34+
sessionID: "test-session",
35+
agent: { name: agentName },
36+
model: { providerID, modelID },
37+
provider: { id: providerID },
38+
message: { variant },
39+
},
40+
output: {
41+
temperature: 0.1,
42+
options: { ...existingOptions },
43+
},
44+
}
45+
}
46+
47+
describe("createAnthropicEffortHook", () => {
48+
describe("opus 4-6 with variant max", () => {
49+
it("should inject effort max for anthropic opus-4-6 with variant max", async () => {
50+
//#given anthropic opus-4-6 model with variant max
51+
const hook = createAnthropicEffortHook()
52+
const { input, output } = createMockParams({})
53+
54+
//#when chat.params hook is called
55+
await hook["chat.params"](input, output)
56+
57+
//#then effort should be injected into options
58+
expect(output.options.effort).toBe("max")
59+
})
60+
61+
it("should inject effort max for github-copilot claude-opus-4-6", async () => {
62+
//#given github-copilot provider with claude-opus-4-6
63+
const hook = createAnthropicEffortHook()
64+
const { input, output } = createMockParams({
65+
providerID: "github-copilot",
66+
modelID: "claude-opus-4-6",
67+
})
68+
69+
//#when chat.params hook is called
70+
await hook["chat.params"](input, output)
71+
72+
//#then effort should be injected (github-copilot resolves to anthropic)
73+
expect(output.options.effort).toBe("max")
74+
})
75+
76+
it("should inject effort max for opencode provider with claude-opus-4-6", async () => {
77+
//#given opencode provider with claude-opus-4-6
78+
const hook = createAnthropicEffortHook()
79+
const { input, output } = createMockParams({
80+
providerID: "opencode",
81+
modelID: "claude-opus-4-6",
82+
})
83+
84+
//#when chat.params hook is called
85+
await hook["chat.params"](input, output)
86+
87+
//#then effort should be injected
88+
expect(output.options.effort).toBe("max")
89+
})
90+
91+
it("should handle normalized model ID with dots (opus-4.6)", async () => {
92+
//#given model ID with dots instead of hyphens
93+
const hook = createAnthropicEffortHook()
94+
const { input, output } = createMockParams({
95+
modelID: "claude-opus-4.6",
96+
})
97+
98+
//#when chat.params hook is called
99+
await hook["chat.params"](input, output)
100+
101+
//#then should normalize and inject effort
102+
expect(output.options.effort).toBe("max")
103+
})
104+
})
105+
106+
describe("conditions NOT met - should skip", () => {
107+
it("should NOT inject effort when variant is not max", async () => {
108+
//#given opus-4-6 with variant high (not max)
109+
const hook = createAnthropicEffortHook()
110+
const { input, output } = createMockParams({ variant: "high" })
111+
112+
//#when chat.params hook is called
113+
await hook["chat.params"](input, output)
114+
115+
//#then effort should NOT be injected
116+
expect(output.options.effort).toBeUndefined()
117+
})
118+
119+
it("should NOT inject effort when variant is undefined", async () => {
120+
//#given opus-4-6 with no variant
121+
const hook = createAnthropicEffortHook()
122+
const { input, output } = createMockParams({ variant: undefined })
123+
124+
//#when chat.params hook is called
125+
await hook["chat.params"](input, output)
126+
127+
//#then effort should NOT be injected
128+
expect(output.options.effort).toBeUndefined()
129+
})
130+
131+
it("should NOT inject effort for non-opus model", async () => {
132+
//#given claude-sonnet-4-5 (not opus)
133+
const hook = createAnthropicEffortHook()
134+
const { input, output } = createMockParams({
135+
modelID: "claude-sonnet-4-5",
136+
})
137+
138+
//#when chat.params hook is called
139+
await hook["chat.params"](input, output)
140+
141+
//#then effort should NOT be injected
142+
expect(output.options.effort).toBeUndefined()
143+
})
144+
145+
it("should NOT inject effort for non-anthropic provider with non-claude model", async () => {
146+
//#given openai provider with gpt model
147+
const hook = createAnthropicEffortHook()
148+
const { input, output } = createMockParams({
149+
providerID: "openai",
150+
modelID: "gpt-5.2",
151+
})
152+
153+
//#when chat.params hook is called
154+
await hook["chat.params"](input, output)
155+
156+
//#then effort should NOT be injected
157+
expect(output.options.effort).toBeUndefined()
158+
})
159+
160+
it("should NOT throw when model.modelID is undefined", async () => {
161+
//#given model with undefined modelID (runtime edge case)
162+
const hook = createAnthropicEffortHook()
163+
const input = {
164+
sessionID: "test-session",
165+
agent: { name: "sisyphus" },
166+
model: { providerID: "anthropic", modelID: undefined as unknown as string },
167+
provider: { id: "anthropic" },
168+
message: { variant: "max" as const },
169+
}
170+
const output = { temperature: 0.1, options: {} }
171+
172+
//#when chat.params hook is called with undefined modelID
173+
await hook["chat.params"](input, output)
174+
175+
//#then should gracefully skip without throwing
176+
expect(output.options.effort).toBeUndefined()
177+
})
178+
})
179+
180+
describe("preserves existing options", () => {
181+
it("should NOT overwrite existing effort if already set", async () => {
182+
//#given options already have effort set
183+
const hook = createAnthropicEffortHook()
184+
const { input, output } = createMockParams({
185+
existingOptions: { effort: "high" },
186+
})
187+
188+
//#when chat.params hook is called
189+
await hook["chat.params"](input, output)
190+
191+
//#then existing effort should be preserved
192+
expect(output.options.effort).toBe("high")
193+
})
194+
195+
it("should preserve other existing options when injecting effort", async () => {
196+
//#given options with existing thinking config
197+
const hook = createAnthropicEffortHook()
198+
const { input, output } = createMockParams({
199+
existingOptions: {
200+
thinking: { type: "enabled", budgetTokens: 31999 },
201+
},
202+
})
203+
204+
//#when chat.params hook is called
205+
await hook["chat.params"](input, output)
206+
207+
//#then effort should be added without affecting thinking
208+
expect(output.options.effort).toBe("max")
209+
expect(output.options.thinking).toEqual({
210+
type: "enabled",
211+
budgetTokens: 31999,
212+
})
213+
})
214+
})
215+
})

0 commit comments

Comments
 (0)