From 98f4adbf4be498ca629b0d36a1945aab6ee61f21 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Fri, 6 Feb 2026 21:38:27 +0900 Subject: [PATCH 1/4] chore: add modular code enforcement rule and unignore .sisyphus/rules/ --- .gitignore | 3 +- .sisyphus/rules/modular-code-enforcement.md | 117 ++++++++++++++++++++ 2 files changed, 119 insertions(+), 1 deletion(-) create mode 100644 .sisyphus/rules/modular-code-enforcement.md diff --git a/.gitignore b/.gitignore index 2f4f5ebf..212fbbe0 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ # Dependencies -.sisyphus/ +.sisyphus/* +!.sisyphus/rules/ node_modules/ # Build output diff --git a/.sisyphus/rules/modular-code-enforcement.md b/.sisyphus/rules/modular-code-enforcement.md new file mode 100644 index 00000000..dea6062b --- /dev/null +++ b/.sisyphus/rules/modular-code-enforcement.md @@ -0,0 +1,117 @@ +--- +globs: ["**/*.ts", "**/*.tsx"] +alwaysApply: false +description: "Enforces strict modular code architecture: SRP, no monolithic index.ts, 200 LOC hard limit" +--- + + + +# 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. + + From 6febebc166c5674089ed80a6a9758be8c356a3ec Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Fri, 6 Feb 2026 21:47:10 +0900 Subject: [PATCH 2/4] feat: add anthropic-effort hook to inject effort=max for Opus 4.6 Injects `output_config: { effort: "max" }` via AI SDK's providerOptions when all conditions are met: - variant is "max" (sisyphus, prometheus, metis, oracle, unspecified-high, ultrawork) - model matches claude-opus-4[-.]6 pattern - provider is anthropic, opencode, or github-copilot (with claude model) Respects existing effort value if already set. Normalizes model IDs with dots to hyphens for consistent matching. --- src/hooks/anthropic-effort/index.test.ts | 196 +++++++++++++++++++++++ src/hooks/anthropic-effort/index.ts | 55 +++++++ 2 files changed, 251 insertions(+) create mode 100644 src/hooks/anthropic-effort/index.test.ts create mode 100644 src/hooks/anthropic-effort/index.ts diff --git a/src/hooks/anthropic-effort/index.test.ts b/src/hooks/anthropic-effort/index.test.ts new file mode 100644 index 00000000..84168fbb --- /dev/null +++ b/src/hooks/anthropic-effort/index.test.ts @@ -0,0 +1,196 @@ +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 +} + +function createMockParams(overrides: { + providerID?: string + modelID?: string + variant?: string + agentName?: string + existingOptions?: Record +}): { 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() + }) + }) + + 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, + }) + }) + }) +}) diff --git a/src/hooks/anthropic-effort/index.ts b/src/hooks/anthropic-effort/index.ts new file mode 100644 index 00000000..b52fc32a --- /dev/null +++ b/src/hooks/anthropic-effort/index.ts @@ -0,0 +1,55 @@ +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 +} + +export function createAnthropicEffortHook() { + return { + "chat.params": async ( + input: ChatParamsInput, + output: ChatParamsOutput + ): Promise => { + const { model, message } = input + 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, + }) + }, + } +} From ec520e6228a2921b758e0222d34eb18e5c41100e Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Fri, 6 Feb 2026 21:47:18 +0900 Subject: [PATCH 3/4] feat: register anthropic-effort hook in plugin lifecycle - Add "anthropic-effort" to HookNameSchema enum - Import and create hook in plugin entry with isHookEnabled guard - Wire chat.params event handler to invoke the effort hook - First hook to use the chat.params lifecycle event from plugin --- src/config/schema.ts | 1 + src/index.ts | 28 ++++++++++++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/src/config/schema.ts b/src/config/schema.ts index 936c96a8..3b70a31f 100644 --- a/src/config/schema.ts +++ b/src/config/schema.ts @@ -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([ diff --git a/src/index.ts b/src/index.ts index f1750aee..ee210e66 100644 --- a/src/index.ts +++ b/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 + provider: Record + message: Record + }, + output: { + temperature: number + topP: number + topK: number + options: Record + }, + ) => { + 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); From cb2169f3341f4f6a36e07f2016d6730478968ab0 Mon Sep 17 00:00:00 2001 From: YeonGyu-Kim Date: Fri, 6 Feb 2026 21:55:13 +0900 Subject: [PATCH 4/4] fix: guard against undefined modelID in anthropic-effort hook Add early return when model.modelID or model.providerID is nullish, preventing TypeError at runtime when chat.params receives incomplete model data. --- src/hooks/anthropic-effort/index.test.ts | 19 +++++++++++++++++++ src/hooks/anthropic-effort/index.ts | 1 + 2 files changed, 20 insertions(+) diff --git a/src/hooks/anthropic-effort/index.test.ts b/src/hooks/anthropic-effort/index.test.ts index 84168fbb..be965c0a 100644 --- a/src/hooks/anthropic-effort/index.test.ts +++ b/src/hooks/anthropic-effort/index.test.ts @@ -156,6 +156,25 @@ describe("createAnthropicEffortHook", () => { //#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", () => { diff --git a/src/hooks/anthropic-effort/index.ts b/src/hooks/anthropic-effort/index.ts index b52fc32a..141933cb 100644 --- a/src/hooks/anthropic-effort/index.ts +++ b/src/hooks/anthropic-effort/index.ts @@ -39,6 +39,7 @@ export function createAnthropicEffortHook() { output: ChatParamsOutput ): Promise => { 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