diff --git a/src/shared/index.ts b/src/shared/index.ts index ce76682..d39fc94 100644 --- a/src/shared/index.ts +++ b/src/shared/index.ts @@ -15,3 +15,4 @@ export * from "./data-path" export * from "./config-errors" export * from "./claude-config-dir" export * from "./jsonc-parser" +export * from "./migration" diff --git a/src/shared/migration.test.ts b/src/shared/migration.test.ts new file mode 100644 index 0000000..fd6c30a --- /dev/null +++ b/src/shared/migration.test.ts @@ -0,0 +1,243 @@ +import { describe, test, expect } from "bun:test" +import { + AGENT_NAME_MAP, + HOOK_NAME_MAP, + migrateAgentNames, + migrateHookNames, + migrateConfigFile, +} from "./migration" + +describe("migrateAgentNames", () => { + test("migrates legacy OmO names to Sisyphus", () => { + // #given: Config with legacy OmO agent names + const agents = { + omo: { model: "anthropic/claude-opus-4-5" }, + OmO: { temperature: 0.5 }, + "OmO-Plan": { prompt: "custom prompt" }, + } + + // #when: Migrate agent names + const { migrated, changed } = migrateAgentNames(agents) + + // #then: Legacy names should be migrated to Sisyphus + expect(changed).toBe(true) + expect(migrated["Sisyphus"]).toEqual({ temperature: 0.5 }) + expect(migrated["Planner-Sisyphus"]).toEqual({ prompt: "custom prompt" }) + expect(migrated["omo"]).toBeUndefined() + expect(migrated["OmO"]).toBeUndefined() + expect(migrated["OmO-Plan"]).toBeUndefined() + }) + + test("preserves current agent names unchanged", () => { + // #given: Config with current agent names + const agents = { + oracle: { model: "openai/gpt-5.2" }, + librarian: { model: "google/gemini-3-flash" }, + explore: { model: "opencode/grok-code" }, + } + + // #when: Migrate agent names + const { migrated, changed } = migrateAgentNames(agents) + + // #then: Current names should remain unchanged + expect(changed).toBe(false) + expect(migrated["oracle"]).toEqual({ model: "openai/gpt-5.2" }) + expect(migrated["librarian"]).toEqual({ model: "google/gemini-3-flash" }) + expect(migrated["explore"]).toEqual({ model: "opencode/grok-code" }) + }) + + test("handles case-insensitive migration", () => { + // #given: Config with mixed case agent names + const agents = { + SISYPHUS: { model: "test" }, + "PLANNER-SISYPHUS": { prompt: "test" }, + } + + // #when: Migrate agent names + const { migrated, changed } = migrateAgentNames(agents) + + // #then: Case-insensitive lookup should migrate correctly + expect(migrated["Sisyphus"]).toEqual({ model: "test" }) + expect(migrated["Planner-Sisyphus"]).toEqual({ prompt: "test" }) + }) + + test("passes through unknown agent names unchanged", () => { + // #given: Config with unknown agent name + const agents = { + "custom-agent": { model: "custom/model" }, + } + + // #when: Migrate agent names + const { migrated, changed } = migrateAgentNames(agents) + + // #then: Unknown names should pass through + expect(changed).toBe(false) + expect(migrated["custom-agent"]).toEqual({ model: "custom/model" }) + }) +}) + +describe("migrateHookNames", () => { + test("migrates anthropic-auto-compact to anthropic-context-window-limit-recovery", () => { + // #given: Config with legacy hook name + const hooks = ["anthropic-auto-compact", "comment-checker"] + + // #when: Migrate hook names + const { migrated, changed } = migrateHookNames(hooks) + + // #then: Legacy hook name should be migrated + expect(changed).toBe(true) + expect(migrated).toContain("anthropic-context-window-limit-recovery") + expect(migrated).toContain("comment-checker") + expect(migrated).not.toContain("anthropic-auto-compact") + }) + + test("preserves current hook names unchanged", () => { + // #given: Config with current hook names + const hooks = [ + "anthropic-context-window-limit-recovery", + "todo-continuation-enforcer", + "session-recovery", + ] + + // #when: Migrate hook names + const { migrated, changed } = migrateHookNames(hooks) + + // #then: Current names should remain unchanged + expect(changed).toBe(false) + expect(migrated).toEqual(hooks) + }) + + test("handles empty hooks array", () => { + // #given: Empty hooks array + const hooks: string[] = [] + + // #when: Migrate hook names + const { migrated, changed } = migrateHookNames(hooks) + + // #then: Should return empty array with no changes + expect(changed).toBe(false) + expect(migrated).toEqual([]) + }) + + test("migrates multiple legacy hook names", () => { + // #given: Multiple legacy hook names (if more are added in future) + const hooks = ["anthropic-auto-compact"] + + // #when: Migrate hook names + const { migrated, changed } = migrateHookNames(hooks) + + // #then: All legacy names should be migrated + expect(changed).toBe(true) + expect(migrated).toEqual(["anthropic-context-window-limit-recovery"]) + }) +}) + +describe("migrateConfigFile", () => { + const testConfigPath = "/tmp/nonexistent-path-for-test.json" + + test("migrates omo_agent to sisyphus_agent", () => { + // #given: Config with legacy omo_agent key + const rawConfig: Record = { + omo_agent: { disabled: false }, + } + + // #when: Migrate config file + const needsWrite = migrateConfigFile(testConfigPath, rawConfig) + + // #then: omo_agent should be migrated to sisyphus_agent + expect(needsWrite).toBe(true) + expect(rawConfig.sisyphus_agent).toEqual({ disabled: false }) + expect(rawConfig.omo_agent).toBeUndefined() + }) + + test("migrates legacy agent names in agents object", () => { + // #given: Config with legacy agent names + const rawConfig: Record = { + agents: { + omo: { model: "test" }, + OmO: { temperature: 0.5 }, + }, + } + + // #when: Migrate config file + const needsWrite = migrateConfigFile(testConfigPath, rawConfig) + + // #then: Agent names should be migrated + expect(needsWrite).toBe(true) + const agents = rawConfig.agents as Record + expect(agents["Sisyphus"]).toBeDefined() + }) + + test("migrates legacy hook names in disabled_hooks", () => { + // #given: Config with legacy hook names + const rawConfig: Record = { + disabled_hooks: ["anthropic-auto-compact", "comment-checker"], + } + + // #when: Migrate config file + const needsWrite = migrateConfigFile(testConfigPath, rawConfig) + + // #then: Hook names should be migrated + expect(needsWrite).toBe(true) + expect(rawConfig.disabled_hooks).toContain("anthropic-context-window-limit-recovery") + expect(rawConfig.disabled_hooks).not.toContain("anthropic-auto-compact") + }) + + test("does not write if no migration needed", () => { + // #given: Config with current names + const rawConfig: Record = { + sisyphus_agent: { disabled: false }, + agents: { + Sisyphus: { model: "test" }, + }, + disabled_hooks: ["anthropic-context-window-limit-recovery"], + } + + // #when: Migrate config file + const needsWrite = migrateConfigFile(testConfigPath, rawConfig) + + // #then: No write should be needed + expect(needsWrite).toBe(false) + }) + + test("handles migration of all legacy items together", () => { + // #given: Config with all legacy items + const rawConfig: Record = { + omo_agent: { disabled: false }, + agents: { + omo: { model: "test" }, + "OmO-Plan": { prompt: "custom" }, + }, + disabled_hooks: ["anthropic-auto-compact"], + } + + // #when: Migrate config file + const needsWrite = migrateConfigFile(testConfigPath, rawConfig) + + // #then: All legacy items should be migrated + expect(needsWrite).toBe(true) + expect(rawConfig.sisyphus_agent).toEqual({ disabled: false }) + expect(rawConfig.omo_agent).toBeUndefined() + const agents = rawConfig.agents as Record + expect(agents["Sisyphus"]).toBeDefined() + expect(agents["Planner-Sisyphus"]).toBeDefined() + expect(rawConfig.disabled_hooks).toContain("anthropic-context-window-limit-recovery") + }) +}) + +describe("migration maps", () => { + test("AGENT_NAME_MAP contains all expected legacy mappings", () => { + // #given/#when: Check AGENT_NAME_MAP + // #then: Should contain all legacy → current mappings + expect(AGENT_NAME_MAP["omo"]).toBe("Sisyphus") + expect(AGENT_NAME_MAP["OmO"]).toBe("Sisyphus") + expect(AGENT_NAME_MAP["OmO-Plan"]).toBe("Planner-Sisyphus") + expect(AGENT_NAME_MAP["omo-plan"]).toBe("Planner-Sisyphus") + }) + + test("HOOK_NAME_MAP contains anthropic-auto-compact migration", () => { + // #given/#when: Check HOOK_NAME_MAP + // #then: Should contain the legacy hook name mapping + expect(HOOK_NAME_MAP["anthropic-auto-compact"]).toBe("anthropic-context-window-limit-recovery") + }) +}) diff --git a/src/shared/migration.ts b/src/shared/migration.ts new file mode 100644 index 0000000..3168293 --- /dev/null +++ b/src/shared/migration.ts @@ -0,0 +1,94 @@ +import * as fs from "fs" +import { log } from "./logger" + +// Migration map: old keys → new keys (for backward compatibility) +export const AGENT_NAME_MAP: Record = { + // Legacy names (backward compatibility) + omo: "Sisyphus", + "OmO": "Sisyphus", + "OmO-Plan": "Planner-Sisyphus", + "omo-plan": "Planner-Sisyphus", + // Current names + sisyphus: "Sisyphus", + "planner-sisyphus": "Planner-Sisyphus", + build: "build", + oracle: "oracle", + librarian: "librarian", + explore: "explore", + "frontend-ui-ux-engineer": "frontend-ui-ux-engineer", + "document-writer": "document-writer", + "multimodal-looker": "multimodal-looker", +} + +// Migration map: old hook names → new hook names (for backward compatibility) +export const HOOK_NAME_MAP: Record = { + // Legacy names (backward compatibility) + "anthropic-auto-compact": "anthropic-context-window-limit-recovery", +} + +export function migrateAgentNames(agents: Record): { migrated: Record; changed: boolean } { + const migrated: Record = {} + let changed = false + + for (const [key, value] of Object.entries(agents)) { + const newKey = AGENT_NAME_MAP[key.toLowerCase()] ?? AGENT_NAME_MAP[key] ?? key + if (newKey !== key) { + changed = true + } + migrated[newKey] = value + } + + return { migrated, changed } +} + +export function migrateHookNames(hooks: string[]): { migrated: string[]; changed: boolean } { + const migrated: string[] = [] + let changed = false + + for (const hook of hooks) { + const newHook = HOOK_NAME_MAP[hook] ?? hook + if (newHook !== hook) { + changed = true + } + migrated.push(newHook) + } + + return { migrated, changed } +} + +export function migrateConfigFile(configPath: string, rawConfig: Record): boolean { + let needsWrite = false + + if (rawConfig.agents && typeof rawConfig.agents === "object") { + const { migrated, changed } = migrateAgentNames(rawConfig.agents as Record) + if (changed) { + rawConfig.agents = migrated + needsWrite = true + } + } + + if (rawConfig.omo_agent) { + rawConfig.sisyphus_agent = rawConfig.omo_agent + delete rawConfig.omo_agent + needsWrite = true + } + + if (rawConfig.disabled_hooks && Array.isArray(rawConfig.disabled_hooks)) { + const { migrated, changed } = migrateHookNames(rawConfig.disabled_hooks as string[]) + if (changed) { + rawConfig.disabled_hooks = migrated + needsWrite = true + } + } + + if (needsWrite) { + try { + fs.writeFileSync(configPath, JSON.stringify(rawConfig, null, 2) + "\n", "utf-8") + log(`Migrated config file: ${configPath}`) + } catch (err) { + log(`Failed to write migrated config to ${configPath}:`, err) + } + } + + return needsWrite +}