diff --git a/packages/api/src/config/cat-config-loader.ts b/packages/api/src/config/cat-config-loader.ts index f144718af..5e074d472 100644 --- a/packages/api/src/config/cat-config-loader.ts +++ b/packages/api/src/config/cat-config-loader.ts @@ -656,6 +656,31 @@ export function getDefaultCatId(): CatId { // ── Variant CLI effort accessor ────────────────────────────────────── +/** + * Provider-aware effort validation. + * Each CLI has its own valid effort values: + * claude (anthropic): low|medium|high|max + * codex (openai): low|medium|high|xhigh + * gemini (google): low|medium|high|max + * opencode (dare): low|medium|high|max + */ +const VALID_EFFORT_BY_PROVIDER: Record = { + anthropic: ['low', 'medium', 'high', 'max'] as const, + openai: ['low', 'medium', 'high', 'xhigh'] as const, + google: ['low', 'medium', 'high', 'max'] as const, + dare: ['low', 'medium', 'high', 'max'] as const, + opencode: ['low', 'medium', 'high', 'max'] as const, + // antigravity, a2a: no effort concept +} as const; + +const DEFAULT_EFFORT_BY_PROVIDER: Record = { + anthropic: 'max', + openai: 'xhigh', + google: 'max', + dare: 'max', + opencode: 'max', +}; + /** catId → variant index (lazy, rebuilt on config change) */ let _catIdToVariant: Map | null = null; let _catIdToVariantSource: CatCafeConfig | null = null; @@ -676,10 +701,13 @@ export type CliEffortLevel = 'low' | 'medium' | 'high' | 'max' | 'xhigh'; /** * Get CLI effort level for a cat from cat-config.json. + * Provider-aware normalization: validates effort values against provider CLI spec, + * auto-maps invalid values to provider-correct defaults. + * * Default when not configured: * claude (anthropic): 'max' * codex (openai): 'xhigh' - * others: 'high' + * others: 'max' */ export function getCatEffort(catId: string, config?: CatCafeConfig): CliEffortLevel { const cfg = config ?? getCachedConfig(); @@ -691,12 +719,29 @@ export function getCatEffort(catId: string, config?: CatCafeConfig): CliEffortLe } const variant = _catIdToVariant.get(catId); - if (variant?.cli.effort) return variant.cli.effort; + if (!variant) return 'max'; + + const provider = variant.provider; + const configuredEffort = variant.cli.effort; + + if (configuredEffort) { + // Validate effort against provider's valid values + const validValues = VALID_EFFORT_BY_PROVIDER[provider]; + if (validValues && validValues.includes(configuredEffort)) { + return configuredEffort; + } + + // Effort value is invalid for this provider — map to default and warn + const defaultValue = DEFAULT_EFFORT_BY_PROVIDER[provider] ?? 'max'; + log.warn( + `Invalid effort "${configuredEffort}" for provider "${provider}" (cat: ${catId}). ` + + `Valid values: ${validValues?.join('|') ?? 'unknown'}. Mapped to "${defaultValue}".`, + ); + return defaultValue; + } - // Provider-aware defaults - if (variant?.provider === 'openai') return 'xhigh'; - if (variant?.provider === 'anthropic') return 'max'; - return 'high'; + // Provider-aware defaults when not configured + return DEFAULT_EFFORT_BY_PROVIDER[provider] ?? 'max'; } /** Reset cached config (for testing) */ diff --git a/packages/api/src/config/governance/governance-bootstrap.ts b/packages/api/src/config/governance/governance-bootstrap.ts index 1ec7e839e..8f7a721fb 100644 --- a/packages/api/src/config/governance/governance-bootstrap.ts +++ b/packages/api/src/config/governance/governance-bootstrap.ts @@ -6,10 +6,13 @@ * and bootstrap reporting. */ +import { existsSync } from 'node:fs'; import { lstat, mkdir, readFile, readlink, symlink, writeFile } from 'node:fs/promises'; import { dirname, relative, resolve, sep } from 'node:path'; import type { BootstrapAction, BootstrapReport } from '@cat-cafe/shared'; import { pathsEqual } from '../../utils/project-path.js'; +import { bootstrapCatCatalog } from '../cat-catalog-store.js'; +import { migrateProviderProfilesToAccounts } from '../migrate-provider-profiles.js'; import type { Provider } from './governance-pack.js'; import { computePackChecksum, @@ -89,7 +92,13 @@ export class GovernanceBootstrapService { actions.push(action); } - // 4. Save bootstrap report + // 4. Runtime catalog + account migration for external project invocation. + // Without this, bound accountRef (e.g. my-glm) can resolve in Cat Cafe root + // but fail in external project threads that only have governance files. + const runtimeCatalogActions = this.bootstrapRuntimeCatalogAndAccounts(targetProject, opts.dryRun); + actions.push(...runtimeCatalogActions); + + // 5. Save bootstrap report const report: BootstrapReport = { projectPath: targetProject, timestamp: Date.now(), @@ -111,6 +120,57 @@ export class GovernanceBootstrapService { return report; } + private bootstrapRuntimeCatalogAndAccounts(targetProject: string, dryRun: boolean): BootstrapAction[] { + const templatePath = resolve(this.catCafeRoot, 'cat-template.json'); + const catalogRelPath = '.cat-cafe/cat-catalog.json'; + const migrationRelPath = '.cat-cafe/accounts-migration'; + const catalogPath = resolve(targetProject, catalogRelPath); + + if (!existsSync(templatePath)) { + return [ + { + file: catalogRelPath, + action: 'skipped', + reason: 'cat-template.json missing in cat-cafe root', + }, + ]; + } + + const catalogExists = existsSync(catalogPath); + const catalogAction: BootstrapAction = { + file: catalogRelPath, + action: catalogExists ? 'skipped' : 'created', + reason: catalogExists ? 'runtime catalog already exists' : 'runtime catalog bootstrapped from cat-template.json', + }; + + if (dryRun) { + return [ + catalogAction, + { + file: migrationRelPath, + action: 'skipped', + reason: 'dry-run: account migration not executed', + }, + ]; + } + + bootstrapCatCatalog(targetProject, templatePath); + const migration = migrateProviderProfilesToAccounts(targetProject); + const migrationAction: BootstrapAction = migration.migrated + ? { + file: migrationRelPath, + action: 'updated', + reason: `migrated ${migration.accountsMigrated ?? 0} account(s), ${migration.credentialsMigrated ?? 0} credential(s)`, + } + : { + file: migrationRelPath, + action: 'skipped', + reason: `migration skipped (${migration.reason ?? 'unknown'})`, + }; + + return [catalogAction, migrationAction]; + } + private async writeManagedBlock( targetProject: string, provider: Provider, diff --git a/packages/api/test/config/cat-config-effort.test.js b/packages/api/test/config/cat-config-effort.test.js new file mode 100644 index 000000000..b1afc5ae0 --- /dev/null +++ b/packages/api/test/config/cat-config-effort.test.js @@ -0,0 +1,316 @@ +/** + * Tests for provider-aware CLI effort normalization (F000-cli-effort) + * + * Bug: cli.effort="max" (Claude's value) was passed directly to Codex CLI + * as model_reasoning_effort="max", but Codex only accepts "xhigh", causing + * immediate CLI startup failure. + * + * Fix: getCatEffort() now validates effort values against provider specs + * and auto-maps invalid values to provider-correct defaults. + */ + +import assert from 'node:assert/strict'; +import { afterEach, beforeEach, describe, it } from 'node:test'; + +describe('getCatEffort - provider-aware effort normalization', () => { + let previousConfig; + let _resetCachedConfig; + + beforeEach(async () => { + // Import fresh module and cache reset function + const module = await import(`../../dist/config/cat-config-loader.js?t=${Date.now()}`); + _resetCachedConfig = module._resetCachedConfig; + // Reset config cache before each test + _resetCachedConfig(); + }); + + afterEach(() => { + // Reset config cache after each test + _resetCachedConfig?.(); + }); + + describe('anthropic (Claude CLI)', () => { + it('accepts valid effort values: low, medium, high, max', async () => { + const { getCatEffort } = await import(`../../dist/config/cat-config-loader.js?t=${Date.now()}`); + + const testVariants = [ + { effort: 'low', expected: 'low' }, + { effort: 'medium', expected: 'medium' }, + { effort: 'high', expected: 'high' }, + { effort: 'max', expected: 'max' }, + ]; + + for (const { effort, expected } of testVariants) { + const mockConfig = { + version: 2, + breeds: [ + { + id: 'test-breed', + catId: 'test-cat', + name: 'Test', + displayName: 'Test Cat', + avatar: 'test.png', + color: { primary: '#fff', secondary: '#000' }, + mentionPatterns: ['@test'], + roleDescription: 'Test', + defaultVariantId: 'v1', + variants: [ + { + id: 'v1', + provider: 'anthropic', + defaultModel: 'claude-3-5-sonnet-20241022', + mcpSupport: true, + cli: { command: 'claude', outputFormat: 'json', effort }, + }, + ], + }, + ], + roster: {}, + reviewPolicy: { + requireDifferentFamily: true, + preferActiveInThread: true, + preferLead: true, + excludeUnavailable: true, + }, + }; + const result = getCatEffort('test-cat', mockConfig); + assert.equal(result, expected, `anthropic: effort="${effort}" should return "${expected}"`); + } + }); + + it('returns default "max" when effort not configured', async () => { + const mockConfig = { + version: 2, + breeds: [ + { + id: 'test-breed', + catId: 'test-cat', + name: 'Test', + displayName: 'Test Cat', + avatar: 'test.png', + color: { primary: '#fff', secondary: '#000' }, + mentionPatterns: ['@test'], + roleDescription: 'Test', + defaultVariantId: 'v1', + variants: [ + { + id: 'v1', + provider: 'anthropic', + defaultModel: 'claude-3-5-sonnet-20241022', + mcpSupport: true, + cli: { command: 'claude', outputFormat: 'json' }, // no effort + }, + ], + }, + ], + roster: {}, + reviewPolicy: { + requireDifferentFamily: true, + preferActiveInThread: true, + preferLead: true, + excludeUnavailable: true, + }, + }; + + const { getCatEffort } = await import(`../../dist/config/cat-config-loader.js?t=${Date.now()}`); + const result = getCatEffort('test-cat', mockConfig); + assert.equal(result, 'max', 'anthropic: default effort should be "max"'); + }); + }); + + describe('openai (Codex CLI)', () => { + it('accepts valid effort values: low, medium, high, xhigh', async () => { + const mockConfig = { + version: 2, + breeds: [ + { + id: 'test-breed', + catId: 'codex-cat', + name: 'Codex', + displayName: 'Codex Cat', + avatar: 'test.png', + color: { primary: '#fff', secondary: '#000' }, + mentionPatterns: ['@codex'], + roleDescription: 'Test', + defaultVariantId: 'v1', + variants: [ + { + id: 'v1', + provider: 'openai', + defaultModel: 'o3-mini', + mcpSupport: true, + cli: { command: 'codex', outputFormat: 'json' }, + }, + ], + }, + ], + roster: {}, + reviewPolicy: { + requireDifferentFamily: true, + preferActiveInThread: true, + preferLead: true, + excludeUnavailable: true, + }, + }; + + const { getCatEffort } = await import(`../../dist/config/cat-config-loader.js?t=${Date.now()}`); + + const testVariants = [ + { effort: 'low', expected: 'low' }, + { effort: 'medium', expected: 'medium' }, + { effort: 'high', expected: 'high' }, + { effort: 'xhigh', expected: 'xhigh' }, + ]; + + for (const { effort, expected } of testVariants) { + mockConfig.breeds[0].variants[0].cli.effort = effort; + const result = getCatEffort('codex-cat', mockConfig); + assert.equal(result, expected, `openai: effort="${effort}" should return "${expected}"`); + } + }); + + it('normalizes invalid "max" to "xhigh" (BUG FIX)', async () => { + const mockConfig = { + version: 2, + breeds: [ + { + id: 'test-breed', + catId: 'codex-cat', + name: 'Codex', + displayName: 'Codex Cat', + avatar: 'test.png', + color: { primary: '#fff', secondary: '#000' }, + mentionPatterns: ['@codex'], + roleDescription: 'Test', + defaultVariantId: 'v1', + variants: [ + { + id: 'v1', + provider: 'openai', + defaultModel: 'o3-mini', + mcpSupport: true, + cli: { command: 'codex', outputFormat: 'json', effort: 'max' }, // INVALID for Codex! + }, + ], + }, + ], + roster: {}, + reviewPolicy: { + requireDifferentFamily: true, + preferActiveInThread: true, + preferLead: true, + excludeUnavailable: true, + }, + }; + + const { getCatEffort } = await import(`../../dist/config/cat-config-loader.js?t=${Date.now()}`); + const result = getCatEffort('codex-cat', mockConfig); + assert.equal(result, 'xhigh', 'openai: invalid effort="max" should normalize to "xhigh"'); + }); + + it('returns default "xhigh" when effort not configured', async () => { + const mockConfig = { + version: 2, + breeds: [ + { + id: 'test-breed', + catId: 'codex-cat', + name: 'Codex', + displayName: 'Codex Cat', + avatar: 'test.png', + color: { primary: '#fff', secondary: '#000' }, + mentionPatterns: ['@codex'], + roleDescription: 'Test', + defaultVariantId: 'v1', + variants: [ + { + id: 'v1', + provider: 'openai', + defaultModel: 'o3-mini', + mcpSupport: true, + cli: { command: 'codex', outputFormat: 'json' }, // no effort + }, + ], + }, + ], + roster: {}, + reviewPolicy: { + requireDifferentFamily: true, + preferActiveInThread: true, + preferLead: true, + excludeUnavailable: true, + }, + }; + + const { getCatEffort } = await import(`../../dist/config/cat-config-loader.js?t=${Date.now()}`); + const result = getCatEffort('codex-cat', mockConfig); + assert.equal(result, 'xhigh', 'openai: default effort should be "xhigh"'); + }); + }); + + describe('google (Gemini CLI)', () => { + it('accepts valid effort values: low, medium, high, max', async () => { + const mockConfig = { + version: 2, + breeds: [ + { + id: 'test-breed', + catId: 'gemini-cat', + name: 'Gemini', + displayName: 'Gemini Cat', + avatar: 'test.png', + color: { primary: '#fff', secondary: '#000' }, + mentionPatterns: ['@gemini'], + roleDescription: 'Test', + defaultVariantId: 'v1', + variants: [ + { + id: 'v1', + provider: 'google', + defaultModel: 'gemini-2.5-flash', + mcpSupport: false, + cli: { command: 'gemini', outputFormat: 'json' }, + }, + ], + }, + ], + roster: {}, + reviewPolicy: { + requireDifferentFamily: true, + preferActiveInThread: true, + preferLead: true, + excludeUnavailable: true, + }, + }; + + const { getCatEffort } = await import(`../../dist/config/cat-config-loader.js?t=${Date.now()}`); + + const testVariants = [ + { effort: 'low', expected: 'low' }, + { effort: 'medium', expected: 'medium' }, + { effort: 'high', expected: 'high' }, + { effort: 'max', expected: 'max' }, + ]; + + for (const { effort, expected } of testVariants) { + mockConfig.breeds[0].variants[0].cli.effort = effort; + const result = getCatEffort('gemini-cat', mockConfig); + assert.equal(result, expected, `google: effort="${effort}" should return "${expected}"`); + } + }); + }); + + describe('edge cases', () => { + it('handles unknown cat with fallback', async () => { + const { getCatEffort } = await import(`../../dist/config/cat-config-loader.js?t=${Date.now()}`); + const result = getCatEffort('nonexistent-cat'); + assert.equal(result, 'max', 'unknown cat should fallback to "max"'); + }); + + it('handles null config with fallback', async () => { + const { getCatEffort } = await import(`../../dist/config/cat-config-loader.js?t=${Date.now()}`); + const result = getCatEffort('any-cat', null); + assert.equal(result, 'max', 'null config should fallback to "max"'); + }); + }); +}); diff --git a/packages/api/test/governance/governance-bootstrap.test.js b/packages/api/test/governance/governance-bootstrap.test.js index c7ff42593..97602dfdd 100644 --- a/packages/api/test/governance/governance-bootstrap.test.js +++ b/packages/api/test/governance/governance-bootstrap.test.js @@ -14,15 +14,44 @@ import { describe('GovernanceBootstrapService', () => { let catCafeRoot; let targetProject; + let previousGlobalRoot; beforeEach(async () => { catCafeRoot = await mkdtemp(join(tmpdir(), 'cat-cafe-root-')); targetProject = await mkdtemp(join(tmpdir(), 'target-project-')); + previousGlobalRoot = process.env.CAT_CAFE_GLOBAL_CONFIG_ROOT; + process.env.CAT_CAFE_GLOBAL_CONFIG_ROOT = catCafeRoot; + + await mkdir(join(catCafeRoot, '.cat-cafe'), { recursive: true }); + // Create cat-cafe-skills source directory (bootstrap symlinks to it) await mkdir(join(catCafeRoot, 'cat-cafe-skills'), { recursive: true }); + + // Runtime bootstrap source template (used to create target .cat-cafe/cat-catalog.json) + await writeFile( + join(catCafeRoot, 'cat-template.json'), + `${JSON.stringify( + { + version: 2, + breeds: [], + roster: {}, + reviewPolicy: { + requireDifferentFamily: true, + preferActiveInThread: true, + preferLead: true, + excludeUnavailable: true, + }, + }, + null, + 2, + )}\n`, + 'utf-8', + ); }); afterEach(async () => { + if (previousGlobalRoot === undefined) delete process.env.CAT_CAFE_GLOBAL_CONFIG_ROOT; + else process.env.CAT_CAFE_GLOBAL_CONFIG_ROOT = previousGlobalRoot; await rm(catCafeRoot, { recursive: true, force: true }); await rm(targetProject, { recursive: true, force: true }); }); @@ -48,6 +77,11 @@ describe('GovernanceBootstrapService', () => { const sop = await readFile(join(targetProject, 'docs/SOP.md'), 'utf-8'); assert.ok(sop.includes('worktree')); + + // Should create runtime catalog for account resolution in external project threads + const runtimeCatalog = JSON.parse(await readFile(join(targetProject, '.cat-cafe', 'cat-catalog.json'), 'utf-8')); + assert.equal(runtimeCatalog.version, 2); + assert.ok(runtimeCatalog.reviewPolicy); }); it('creates skills symlinks for all 3 providers', async () => { @@ -169,6 +203,65 @@ describe('GovernanceBootstrapService', () => { } }); + it('migrates legacy provider profiles into target project catalog accounts', async () => { + await writeFile( + join(catCafeRoot, '.cat-cafe', 'provider-profiles.json'), + JSON.stringify( + { + version: 3, + activeProfileId: null, + providers: [ + { + id: 'my-glm', + displayName: 'My GLM', + kind: 'api_key', + authType: 'api_key', + builtin: false, + protocol: 'openai', + baseUrl: 'https://open.bigmodel.cn/api/paas/v4', + models: ['glm-5'], + createdAt: '2026-01-01', + updatedAt: '2026-01-01', + }, + ], + bootstrapBindings: {}, + }, + null, + 2, + ), + 'utf-8', + ); + + await writeFile( + join(catCafeRoot, '.cat-cafe', 'provider-profiles.secrets.local.json'), + JSON.stringify( + { + version: 3, + profiles: { + 'my-glm': { apiKey: 'glm-key-xxx' }, + }, + }, + null, + 2, + ), + 'utf-8', + ); + + const svc = new GovernanceBootstrapService(catCafeRoot); + const report = await svc.bootstrap(targetProject, { dryRun: false }); + + const migrationAction = report.actions.find((a) => a.file === '.cat-cafe/accounts-migration'); + assert.ok(migrationAction); + assert.equal(migrationAction.action, 'updated'); + + const catalog = JSON.parse(await readFile(join(targetProject, '.cat-cafe', 'cat-catalog.json'), 'utf-8')); + assert.equal(catalog.accounts['my-glm'].protocol, 'openai'); + assert.equal(catalog.accounts['my-glm'].authType, 'api_key'); + + const creds = JSON.parse(await readFile(join(catCafeRoot, '.cat-cafe', 'credentials.json'), 'utf-8')); + assert.equal(creds['my-glm'].apiKey, 'glm-key-xxx'); + }); + it('creates hooks symlink for claude provider', async () => { // Create source hooks dir in catCafeRoot await mkdir(join(catCafeRoot, '.claude', 'hooks'), { recursive: true });