diff --git a/src/__tests__/catalog.test.ts b/src/__tests__/catalog.test.ts index af9863b..514ea9d 100644 --- a/src/__tests__/catalog.test.ts +++ b/src/__tests__/catalog.test.ts @@ -17,8 +17,17 @@ import { // Mock external dependencies to isolate unit tests vi.mock('../infra/config/global/globalConfig.js', () => ({ - getLanguage: () => 'en', - getBuiltinPiecesEnabled: () => true, + loadGlobalConfig: () => ({}), +})); + +vi.mock('../infra/config/loadConfig.js', () => ({ + loadConfig: () => ({ + global: { + language: 'en', + enableBuiltinPieces: true, + }, + project: {}, + }), })); const mockLogError = vi.fn(); diff --git a/src/__tests__/cli-routing-issue-resolve.test.ts b/src/__tests__/cli-routing-issue-resolve.test.ts index 72b99b3..483b5c3 100644 --- a/src/__tests__/cli-routing-issue-resolve.test.ts +++ b/src/__tests__/cli-routing-issue-resolve.test.ts @@ -76,7 +76,7 @@ vi.mock('../infra/task/index.js', () => ({ vi.mock('../infra/config/index.js', () => ({ getPieceDescription: vi.fn(() => ({ name: 'default', description: 'test piece', pieceStructure: '', movementPreviews: [] })), - loadConfig: vi.fn(() => ({ global: { interactivePreviewMovements: 3 }, project: {} })), + resolveConfigValues: vi.fn(() => ({ language: 'en', interactivePreviewMovements: 3, provider: 'claude' })), })); vi.mock('../shared/constants.js', () => ({ @@ -107,7 +107,7 @@ vi.mock('../app/cli/helpers.js', () => ({ import { checkGhCli, fetchIssue, formatIssueAsTask, parseIssueNumbers } from '../infra/github/issue.js'; import { selectAndExecuteTask, determinePiece, createIssueFromTask, saveTaskFromInteractive } from '../features/tasks/index.js'; import { interactiveMode, selectRecentSession } from '../features/interactive/index.js'; -import { loadConfig } from '../infra/config/index.js'; +import { resolveConfigValues } from '../infra/config/index.js'; import { confirm } from '../shared/prompt/index.js'; import { isDirectTask } from '../app/cli/helpers.js'; import { executeDefaultAction } from '../app/cli/routing.js'; @@ -123,7 +123,7 @@ const mockCreateIssueFromTask = vi.mocked(createIssueFromTask); const mockSaveTaskFromInteractive = vi.mocked(saveTaskFromInteractive); const mockInteractiveMode = vi.mocked(interactiveMode); const mockSelectRecentSession = vi.mocked(selectRecentSession); -const mockLoadConfig = vi.mocked(loadConfig); +const mockResolveConfigValues = vi.mocked(resolveConfigValues); const mockConfirm = vi.mocked(confirm); const mockIsDirectTask = vi.mocked(isDirectTask); const mockTaskRunnerListAllTaskItems = vi.mocked(mockListAllTaskItems); @@ -483,7 +483,7 @@ describe('Issue resolution in routing', () => { describe('session selection with provider=claude', () => { it('should pass selected session ID to interactiveMode when provider is claude', async () => { // Given - mockLoadConfig.mockReturnValue({ global: { interactivePreviewMovements: 3, provider: 'claude' }, project: {} }); + mockResolveConfigValues.mockReturnValue({ language: 'en', interactivePreviewMovements: 3, provider: 'claude' }); mockConfirm.mockResolvedValue(true); mockSelectRecentSession.mockResolvedValue('session-xyz'); @@ -506,7 +506,7 @@ describe('Issue resolution in routing', () => { it('should not call selectRecentSession when user selects no in confirmation', async () => { // Given - mockLoadConfig.mockReturnValue({ global: { interactivePreviewMovements: 3, provider: 'claude' }, project: {} }); + mockResolveConfigValues.mockReturnValue({ language: 'en', interactivePreviewMovements: 3, provider: 'claude' }); mockConfirm.mockResolvedValue(false); // When @@ -525,7 +525,7 @@ describe('Issue resolution in routing', () => { it('should not call selectRecentSession when provider is not claude', async () => { // Given - mockLoadConfig.mockReturnValue({ global: { interactivePreviewMovements: 3, provider: 'openai' }, project: {} }); + mockResolveConfigValues.mockReturnValue({ language: 'en', interactivePreviewMovements: 3, provider: 'openai' }); // When await executeDefaultAction(); diff --git a/src/__tests__/config.test.ts b/src/__tests__/config.test.ts index 492cfd2..4dc3dea 100644 --- a/src/__tests__/config.test.ts +++ b/src/__tests__/config.test.ts @@ -41,13 +41,13 @@ import { describe('getBuiltinPiece', () => { it('should return builtin piece when it exists in resources', () => { - const piece = getBuiltinPiece('default'); + const piece = getBuiltinPiece('default', process.cwd()); expect(piece).not.toBeNull(); expect(piece!.name).toBe('default'); }); it('should resolve builtin instruction_template without projectCwd', () => { - const piece = getBuiltinPiece('default'); + const piece = getBuiltinPiece('default', process.cwd()); expect(piece).not.toBeNull(); const planMovement = piece!.movements.find((movement) => movement.name === 'plan'); @@ -56,15 +56,15 @@ describe('getBuiltinPiece', () => { }); it('should return null for non-existent piece names', () => { - expect(getBuiltinPiece('nonexistent-piece')).toBeNull(); - expect(getBuiltinPiece('unknown')).toBeNull(); - expect(getBuiltinPiece('')).toBeNull(); + expect(getBuiltinPiece('nonexistent-piece', process.cwd())).toBeNull(); + expect(getBuiltinPiece('unknown', process.cwd())).toBeNull(); + expect(getBuiltinPiece('', process.cwd())).toBeNull(); }); }); describe('default piece parallel reviewers movement', () => { it('should have a reviewers movement with parallel sub-movements', () => { - const piece = getBuiltinPiece('default'); + const piece = getBuiltinPiece('default', process.cwd()); expect(piece).not.toBeNull(); const reviewersMovement = piece!.movements.find((s) => s.name === 'reviewers'); @@ -74,7 +74,7 @@ describe('default piece parallel reviewers movement', () => { }); it('should have arch-review and qa-review as parallel sub-movements', () => { - const piece = getBuiltinPiece('default'); + const piece = getBuiltinPiece('default', process.cwd()); const reviewersMovement = piece!.movements.find((s) => s.name === 'reviewers')!; const subMovementNames = reviewersMovement.parallel!.map((s) => s.name); @@ -83,7 +83,7 @@ describe('default piece parallel reviewers movement', () => { }); it('should have aggregate conditions on the reviewers parent movement', () => { - const piece = getBuiltinPiece('default'); + const piece = getBuiltinPiece('default', process.cwd()); const reviewersMovement = piece!.movements.find((s) => s.name === 'reviewers')!; expect(reviewersMovement.rules).toBeDefined(); @@ -101,7 +101,7 @@ describe('default piece parallel reviewers movement', () => { }); it('should have matching conditions on sub-movements for aggregation', () => { - const piece = getBuiltinPiece('default'); + const piece = getBuiltinPiece('default', process.cwd()); const reviewersMovement = piece!.movements.find((s) => s.name === 'reviewers')!; for (const subMovement of reviewersMovement.parallel!) { @@ -113,7 +113,7 @@ describe('default piece parallel reviewers movement', () => { }); it('should have ai_review transitioning to reviewers movement', () => { - const piece = getBuiltinPiece('default'); + const piece = getBuiltinPiece('default', process.cwd()); const aiReviewMovement = piece!.movements.find((s) => s.name === 'ai_review')!; const approveRule = aiReviewMovement.rules!.find((r) => r.next === 'reviewers'); @@ -121,7 +121,7 @@ describe('default piece parallel reviewers movement', () => { }); it('should have ai_fix transitioning to ai_review movement', () => { - const piece = getBuiltinPiece('default'); + const piece = getBuiltinPiece('default', process.cwd()); const aiFixMovement = piece!.movements.find((s) => s.name === 'ai_fix')!; const fixedRule = aiFixMovement.rules!.find((r) => r.next === 'ai_review'); @@ -129,7 +129,7 @@ describe('default piece parallel reviewers movement', () => { }); it('should have fix movement transitioning back to reviewers', () => { - const piece = getBuiltinPiece('default'); + const piece = getBuiltinPiece('default', process.cwd()); const fixMovement = piece!.movements.find((s) => s.name === 'fix')!; const fixedRule = fixMovement.rules!.find((r) => r.next === 'reviewers'); @@ -137,7 +137,7 @@ describe('default piece parallel reviewers movement', () => { }); it('should not have old separate review/security_review/improve movements', () => { - const piece = getBuiltinPiece('default'); + const piece = getBuiltinPiece('default', process.cwd()); const movementNames = piece!.movements.map((s) => s.name); expect(movementNames).not.toContain('review'); @@ -147,7 +147,7 @@ describe('default piece parallel reviewers movement', () => { }); it('should have sub-movements with correct agents', () => { - const piece = getBuiltinPiece('default'); + const piece = getBuiltinPiece('default', process.cwd()); const reviewersMovement = piece!.movements.find((s) => s.name === 'reviewers')!; const archReview = reviewersMovement.parallel!.find((s) => s.name === 'arch-review')!; @@ -158,7 +158,7 @@ describe('default piece parallel reviewers movement', () => { }); it('should have output contracts configured on sub-movements', () => { - const piece = getBuiltinPiece('default'); + const piece = getBuiltinPiece('default', process.cwd()); const reviewersMovement = piece!.movements.find((s) => s.name === 'reviewers')!; const archReview = reviewersMovement.parallel!.find((s) => s.name === 'arch-review')!; @@ -290,7 +290,7 @@ describe('loadPersonaPromptFromPath (builtin paths)', () => { const personaPath = join(builtinPersonasDir, 'coder.md'); if (existsSync(personaPath)) { - const prompt = loadPersonaPromptFromPath(personaPath); + const prompt = loadPersonaPromptFromPath(personaPath, process.cwd()); expect(prompt).toBeTruthy(); expect(typeof prompt).toBe('string'); } diff --git a/src/__tests__/engine-provider-options.test.ts b/src/__tests__/engine-provider-options.test.ts index 24cab26..c17b3af 100644 --- a/src/__tests__/engine-provider-options.test.ts +++ b/src/__tests__/engine-provider-options.test.ts @@ -78,12 +78,9 @@ describe('PieceEngine provider_options resolution', () => { engine = new PieceEngine(config, tmpDir, 'test task', { projectCwd: tmpDir, provider: 'claude', - globalProviderOptions: { + providerOptions: { codex: { networkAccess: true }, claude: { sandbox: { allowUnsandboxedCommands: false } }, - }, - projectProviderOptions: { - claude: { sandbox: { allowUnsandboxedCommands: true } }, opencode: { networkAccess: true }, }, }); @@ -96,7 +93,7 @@ describe('PieceEngine provider_options resolution', () => { opencode: { networkAccess: true }, claude: { sandbox: { - allowUnsandboxedCommands: true, + allowUnsandboxedCommands: false, excludedCommands: ['./gradlew'], }, }, @@ -123,7 +120,7 @@ describe('PieceEngine provider_options resolution', () => { engine = new PieceEngine(config, tmpDir, 'test task', { projectCwd: tmpDir, provider: 'claude', - globalProviderOptions: { + providerOptions: { codex: { networkAccess: true }, }, }); diff --git a/src/__tests__/global-pieceCategories.test.ts b/src/__tests__/global-pieceCategories.test.ts index 286ac22..dc148d1 100644 --- a/src/__tests__/global-pieceCategories.test.ts +++ b/src/__tests__/global-pieceCategories.test.ts @@ -7,14 +7,14 @@ import { tmpdir } from 'node:os'; import { dirname, join } from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -const loadGlobalConfigMock = vi.hoisted(() => vi.fn()); +const loadConfigMock = vi.hoisted(() => vi.fn()); vi.mock('../infra/config/paths.js', () => ({ getGlobalConfigDir: () => '/tmp/.takt', })); -vi.mock('../infra/config/global/globalConfig.js', () => ({ - loadGlobalConfig: loadGlobalConfigMock, +vi.mock('../infra/config/loadConfig.js', () => ({ + loadConfig: loadConfigMock, })); const { getPieceCategoriesPath, resetPieceCategories } = await import( @@ -28,17 +28,18 @@ function createTempCategoriesPath(): string { describe('getPieceCategoriesPath', () => { beforeEach(() => { - loadGlobalConfigMock.mockReset(); + loadConfigMock.mockReset(); }); it('should return configured path when pieceCategoriesFile is set', () => { // Given - loadGlobalConfigMock.mockReturnValue({ - pieceCategoriesFile: '/custom/piece-categories.yaml', + loadConfigMock.mockReturnValue({ + global: { pieceCategoriesFile: '/custom/piece-categories.yaml' }, + project: {}, }); // When - const path = getPieceCategoriesPath(); + const path = getPieceCategoriesPath(process.cwd()); // Then expect(path).toBe('/custom/piece-categories.yaml'); @@ -46,10 +47,10 @@ describe('getPieceCategoriesPath', () => { it('should return default path when pieceCategoriesFile is not set', () => { // Given - loadGlobalConfigMock.mockReturnValue({}); + loadConfigMock.mockReturnValue({ global: {}, project: {} }); // When - const path = getPieceCategoriesPath(); + const path = getPieceCategoriesPath(process.cwd()); // Then expect(path).toBe('/tmp/.takt/preferences/piece-categories.yaml'); @@ -57,12 +58,12 @@ describe('getPieceCategoriesPath', () => { it('should rethrow when global config loading fails', () => { // Given - loadGlobalConfigMock.mockImplementation(() => { + loadConfigMock.mockImplementation(() => { throw new Error('invalid global config'); }); // When / Then - expect(() => getPieceCategoriesPath()).toThrow('invalid global config'); + expect(() => getPieceCategoriesPath(process.cwd())).toThrow('invalid global config'); }); }); @@ -70,7 +71,7 @@ describe('resetPieceCategories', () => { const tempRoots: string[] = []; beforeEach(() => { - loadGlobalConfigMock.mockReset(); + loadConfigMock.mockReset(); }); afterEach(() => { @@ -84,12 +85,13 @@ describe('resetPieceCategories', () => { // Given const categoriesPath = createTempCategoriesPath(); tempRoots.push(dirname(dirname(categoriesPath))); - loadGlobalConfigMock.mockReturnValue({ - pieceCategoriesFile: categoriesPath, + loadConfigMock.mockReturnValue({ + global: { pieceCategoriesFile: categoriesPath }, + project: {}, }); // When - resetPieceCategories(); + resetPieceCategories(process.cwd()); // Then expect(existsSync(dirname(categoriesPath))).toBe(true); @@ -102,14 +104,15 @@ describe('resetPieceCategories', () => { const categoriesDir = dirname(categoriesPath); const tempRoot = dirname(categoriesDir); tempRoots.push(tempRoot); - loadGlobalConfigMock.mockReturnValue({ - pieceCategoriesFile: categoriesPath, + loadConfigMock.mockReturnValue({ + global: { pieceCategoriesFile: categoriesPath }, + project: {}, }); mkdirSync(categoriesDir, { recursive: true }); writeFileSync(categoriesPath, 'piece_categories:\n old:\n - stale-piece\n', 'utf-8'); // When - resetPieceCategories(); + resetPieceCategories(process.cwd()); // Then expect(readFileSync(categoriesPath, 'utf-8')).toBe('piece_categories: {}\n'); diff --git a/src/__tests__/it-notification-sound.test.ts b/src/__tests__/it-notification-sound.test.ts index 5f4d4a0..de91f1d 100644 --- a/src/__tests__/it-notification-sound.test.ts +++ b/src/__tests__/it-notification-sound.test.ts @@ -118,6 +118,10 @@ vi.mock('../infra/config/index.js', () => ({ loadWorktreeSessions: vi.fn().mockReturnValue({}), updateWorktreeSession: vi.fn(), loadGlobalConfig: mockLoadGlobalConfig, + loadConfig: vi.fn().mockImplementation(() => ({ + global: mockLoadGlobalConfig(), + project: {}, + })), saveSessionState: vi.fn(), ensureDir: vi.fn(), writeFileAtomic: vi.fn(), diff --git a/src/__tests__/it-piece-loader.test.ts b/src/__tests__/it-piece-loader.test.ts index 1572c14..5ce414b 100644 --- a/src/__tests__/it-piece-loader.test.ts +++ b/src/__tests__/it-piece-loader.test.ts @@ -4,7 +4,7 @@ * Tests the 3-tier piece resolution (project-local → user → builtin) * and YAML parsing including special rule syntax (ai(), all(), any()). * - * Mocked: globalConfig (for language/builtins) + * Mocked: loadConfig (for language/builtins) * Not mocked: loadPiece, parsePiece, rule parsing */ @@ -18,9 +18,17 @@ const languageState = vi.hoisted(() => ({ value: 'en' as 'en' | 'ja' })); vi.mock('../infra/config/global/globalConfig.js', () => ({ loadGlobalConfig: vi.fn().mockReturnValue({}), - getLanguage: vi.fn(() => languageState.value), - getDisabledBuiltins: vi.fn().mockReturnValue([]), - getBuiltinPiecesEnabled: vi.fn().mockReturnValue(true), +})); + +vi.mock('../infra/config/loadConfig.js', () => ({ + loadConfig: vi.fn(() => ({ + global: { + language: languageState.value, + disabledBuiltins: [], + enableBuiltinPieces: true, + }, + project: {}, + })), })); // --- Imports (after mocks) --- @@ -38,6 +46,7 @@ function createTestDir(): string { describe('Piece Loader IT: builtin piece loading', () => { let testDir: string; + const builtinNames = listBuiltinPieceNames(process.cwd(), { includeDisabled: true }); beforeEach(() => { testDir = createTestDir(); @@ -48,8 +57,6 @@ describe('Piece Loader IT: builtin piece loading', () => { rmSync(testDir, { recursive: true, force: true }); }); - const builtinNames = listBuiltinPieceNames({ includeDisabled: true }); - for (const name of builtinNames) { it(`should load builtin piece: ${name}`, () => { const config = loadPiece(name, testDir); @@ -85,7 +92,7 @@ describe('Piece Loader IT: builtin piece loading', () => { it('should load e2e-test as a builtin piece in ja locale', () => { languageState.value = 'ja'; - const jaBuiltinNames = listBuiltinPieceNames({ includeDisabled: true }); + const jaBuiltinNames = listBuiltinPieceNames(testDir, { includeDisabled: true }); expect(jaBuiltinNames).toContain('e2e-test'); const config = loadPiece('e2e-test', testDir); diff --git a/src/__tests__/it-piece-patterns.test.ts b/src/__tests__/it-piece-patterns.test.ts index 9c1fb65..88dce19 100644 --- a/src/__tests__/it-piece-patterns.test.ts +++ b/src/__tests__/it-piece-patterns.test.ts @@ -57,6 +57,17 @@ vi.mock('../infra/config/project/projectConfig.js', () => ({ loadProjectConfig: vi.fn().mockReturnValue({}), })); +vi.mock('../infra/config/loadConfig.js', () => ({ + loadConfig: vi.fn().mockReturnValue({ + global: { + language: 'en', + enableBuiltinPieces: true, + disabledBuiltins: [], + }, + project: {}, + }), +})); + // --- Imports (after mocks) --- import { PieceEngine } from '../core/piece/index.js'; diff --git a/src/__tests__/it-pipeline-modes.test.ts b/src/__tests__/it-pipeline-modes.test.ts index ee9314b..e2aa3e2 100644 --- a/src/__tests__/it-pipeline-modes.test.ts +++ b/src/__tests__/it-pipeline-modes.test.ts @@ -118,7 +118,11 @@ vi.mock('../infra/config/global/globalConfig.js', async (importOriginal) => { const original = await importOriginal(); return { ...original, - loadGlobalConfig: vi.fn().mockReturnValue({}), + loadGlobalConfig: vi.fn().mockReturnValue({ + language: 'en', + enableBuiltinPieces: true, + disabledBuiltins: [], + }), getLanguage: vi.fn().mockReturnValue('en'), getDisabledBuiltins: vi.fn().mockReturnValue([]), }; diff --git a/src/__tests__/it-pipeline.test.ts b/src/__tests__/it-pipeline.test.ts index 1262957..3efcf89 100644 --- a/src/__tests__/it-pipeline.test.ts +++ b/src/__tests__/it-pipeline.test.ts @@ -100,7 +100,11 @@ vi.mock('../infra/config/global/globalConfig.js', async (importOriginal) => { const original = await importOriginal(); return { ...original, - loadGlobalConfig: vi.fn().mockReturnValue({}), + loadGlobalConfig: vi.fn().mockReturnValue({ + language: 'en', + enableBuiltinPieces: true, + disabledBuiltins: [], + }), getLanguage: vi.fn().mockReturnValue('en'), }; }); diff --git a/src/__tests__/it-sigint-interrupt.test.ts b/src/__tests__/it-sigint-interrupt.test.ts index e15226b..dfe3e9f 100644 --- a/src/__tests__/it-sigint-interrupt.test.ts +++ b/src/__tests__/it-sigint-interrupt.test.ts @@ -89,6 +89,10 @@ vi.mock('../infra/config/index.js', () => ({ loadWorktreeSessions: vi.fn().mockReturnValue({}), updateWorktreeSession: vi.fn(), loadGlobalConfig: vi.fn().mockReturnValue({ provider: 'claude' }), + loadConfig: vi.fn().mockReturnValue({ + global: { provider: 'claude' }, + project: {}, + }), saveSessionState: vi.fn(), ensureDir: vi.fn(), writeFileAtomic: vi.fn(), diff --git a/src/__tests__/options-builder.test.ts b/src/__tests__/options-builder.test.ts index bda6033..c9e2998 100644 --- a/src/__tests__/options-builder.test.ts +++ b/src/__tests__/options-builder.test.ts @@ -16,8 +16,8 @@ function createMovement(overrides: Partial = {}): PieceMovement { function createBuilder(step: PieceMovement, engineOverrides: Partial = {}): OptionsBuilder { const engineOptions: PieceEngineOptions = { projectCwd: '/project', - globalProvider: 'codex', - globalProviderProfiles: { + provider: 'codex', + providerProfiles: { codex: { defaultPermissionMode: 'full', }, @@ -60,10 +60,8 @@ describe('OptionsBuilder.buildBaseOptions', () => { it('uses default profile when provider_profiles are not provided', () => { const step = createMovement(); const builder = createBuilder(step, { - globalProvider: undefined, - globalProviderProfiles: undefined, - projectProvider: undefined, provider: undefined, + providerProfiles: undefined, }); const options = builder.buildBaseOptions(step); @@ -78,11 +76,8 @@ describe('OptionsBuilder.buildBaseOptions', () => { }, }); const builder = createBuilder(step, { - globalProviderOptions: { + providerOptions: { codex: { networkAccess: true }, - claude: { sandbox: { allowUnsandboxedCommands: false } }, - }, - projectProviderOptions: { claude: { sandbox: { allowUnsandboxedCommands: true } }, opencode: { networkAccess: true }, }, @@ -105,10 +100,7 @@ describe('OptionsBuilder.buildBaseOptions', () => { it('falls back to global/project provider options when movement has none', () => { const step = createMovement(); const builder = createBuilder(step, { - globalProviderOptions: { - codex: { networkAccess: true }, - }, - projectProviderOptions: { + providerOptions: { codex: { networkAccess: false }, }, }); diff --git a/src/__tests__/piece-builtin-toggle.test.ts b/src/__tests__/piece-builtin-toggle.test.ts index 8ae0242..c5b00bd 100644 --- a/src/__tests__/piece-builtin-toggle.test.ts +++ b/src/__tests__/piece-builtin-toggle.test.ts @@ -17,6 +17,17 @@ vi.mock('../infra/config/global/globalConfig.js', async (importOriginal) => { }; }); +vi.mock('../infra/config/loadConfig.js', () => ({ + loadConfig: () => ({ + global: { + language: 'en', + enableBuiltinPieces: false, + disabledBuiltins: [], + }, + project: {}, + }), +})); + const { listPieces } = await import('../infra/config/loaders/pieceLoader.js'); const SAMPLE_PIECE = `name: test-piece diff --git a/src/__tests__/piece-category-config.test.ts b/src/__tests__/piece-category-config.test.ts index 1a164ff..a485a4d 100644 --- a/src/__tests__/piece-category-config.test.ts +++ b/src/__tests__/piece-category-config.test.ts @@ -22,12 +22,21 @@ vi.mock('../infra/config/global/globalConfig.js', async (importOriginal) => { const original = await importOriginal() as Record; return { ...original, - getLanguage: () => languageState.value, - getBuiltinPiecesEnabled: () => true, - getDisabledBuiltins: () => [], + loadGlobalConfig: () => ({}), }; }); +vi.mock('../infra/config/loadConfig.js', () => ({ + loadConfig: () => ({ + global: { + language: languageState.value, + enableBuiltinPieces: true, + disabledBuiltins: [], + }, + project: {}, + }), +})); + vi.mock('../infra/resources/index.js', async (importOriginal) => { const original = await importOriginal() as Record; return { @@ -92,7 +101,7 @@ describe('piece category config loading', () => { }); it('should return null when builtin categories file is missing', () => { - const config = getPieceCategories(); + const config = getPieceCategories(testDir); expect(config).toBeNull(); }); @@ -104,7 +113,7 @@ piece_categories: - default `); - const config = loadDefaultCategories(); + const config = loadDefaultCategories(testDir); expect(config).not.toBeNull(); expect(config!.pieceCategories).toEqual([ { name: 'Quick Start', pieces: ['default'], children: [] }, @@ -125,7 +134,7 @@ show_others_category: true others_category_name: Others `); - const config = getPieceCategories(); + const config = getPieceCategories(testDir); expect(config).not.toBeNull(); expect(config!.pieceCategories).toEqual([ { name: 'Main', pieces: ['default'], children: [] }, @@ -165,7 +174,7 @@ show_others_category: false others_category_name: Unclassified `); - const config = getPieceCategories(); + const config = getPieceCategories(testDir); expect(config).not.toBeNull(); expect(config!.pieceCategories).toEqual([ { @@ -207,7 +216,7 @@ piece_categories: - e2e-test `); - const config = getPieceCategories(); + const config = getPieceCategories(testDir); expect(config).not.toBeNull(); expect(config!.pieceCategories).toEqual([ { name: 'レビュー', pieces: ['review-only', 'e2e-test'], children: [] }, @@ -232,7 +241,7 @@ show_others_category: false others_category_name: Unclassified `); - const config = getPieceCategories(); + const config = getPieceCategories(testDir); expect(config).not.toBeNull(); expect(config!.pieceCategories).toEqual([ { name: 'Main', pieces: ['default'], children: [] }, @@ -278,7 +287,7 @@ describe('buildCategorizedPieces', () => { othersCategoryName: 'Others', }; - const categorized = buildCategorizedPieces(allPieces, config); + const categorized = buildCategorizedPieces(allPieces, config, process.cwd()); expect(categorized.categories).toEqual([ { name: 'Main', @@ -310,7 +319,7 @@ describe('buildCategorizedPieces', () => { othersCategoryName: 'Others', }; - const categorized = buildCategorizedPieces(allPieces, config); + const categorized = buildCategorizedPieces(allPieces, config, process.cwd()); expect(categorized.categories).toEqual([ { name: 'Main', pieces: ['default'], children: [] }, { name: 'Others', pieces: ['extra'], children: [] }, @@ -334,7 +343,7 @@ describe('buildCategorizedPieces', () => { othersCategoryName: 'Others', }; - const categorized = buildCategorizedPieces(allPieces, config); + const categorized = buildCategorizedPieces(allPieces, config, process.cwd()); expect(categorized.categories).toEqual([ { name: 'Main', pieces: ['default'], children: [] }, ]); diff --git a/src/__tests__/pieceExecution-debug-prompts.test.ts b/src/__tests__/pieceExecution-debug-prompts.test.ts index 5fb8402..d6ab8e5 100644 --- a/src/__tests__/pieceExecution-debug-prompts.test.ts +++ b/src/__tests__/pieceExecution-debug-prompts.test.ts @@ -91,6 +91,10 @@ vi.mock('../infra/config/index.js', () => ({ loadWorktreeSessions: vi.fn().mockReturnValue({}), updateWorktreeSession: vi.fn(), loadGlobalConfig: vi.fn().mockReturnValue({ provider: 'claude' }), + loadConfig: vi.fn().mockReturnValue({ + global: { provider: 'claude' }, + project: {}, + }), saveSessionState: vi.fn(), ensureDir: vi.fn(), writeFileAtomic: vi.fn(), diff --git a/src/__tests__/resetCategories.test.ts b/src/__tests__/resetCategories.test.ts index 6ab7577..1623955 100644 --- a/src/__tests__/resetCategories.test.ts +++ b/src/__tests__/resetCategories.test.ts @@ -31,13 +31,14 @@ describe('resetCategoriesToDefault', () => { it('should reset user category overlay and show updated message', async () => { // Given + const cwd = '/tmp/test-cwd'; // When - await resetCategoriesToDefault(); + await resetCategoriesToDefault(cwd); // Then expect(mockHeader).toHaveBeenCalledWith('Reset Categories'); - expect(mockResetPieceCategories).toHaveBeenCalledTimes(1); + expect(mockResetPieceCategories).toHaveBeenCalledWith(cwd); expect(mockSuccess).toHaveBeenCalledWith('User category overlay reset.'); expect(mockInfo).toHaveBeenCalledWith(' /tmp/user-piece-categories.yaml'); }); diff --git a/src/__tests__/taskExecution.test.ts b/src/__tests__/taskExecution.test.ts index dbe547d..349fd9b 100644 --- a/src/__tests__/taskExecution.test.ts +++ b/src/__tests__/taskExecution.test.ts @@ -5,13 +5,12 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import type { TaskInfo } from '../infra/task/index.js'; -const { mockResolveTaskExecution, mockExecutePiece, mockLoadPieceByIdentifier, mockLoadGlobalConfig, mockLoadProjectConfig, mockBuildTaskResult, mockPersistTaskResult, mockPersistTaskError, mockPostExecutionFlow } = +const { mockResolveTaskExecution, mockExecutePiece, mockLoadPieceByIdentifier, mockResolveConfigValues, mockBuildTaskResult, mockPersistTaskResult, mockPersistTaskError, mockPostExecutionFlow } = vi.hoisted(() => ({ mockResolveTaskExecution: vi.fn(), mockExecutePiece: vi.fn(), mockLoadPieceByIdentifier: vi.fn(), - mockLoadGlobalConfig: vi.fn(), - mockLoadProjectConfig: vi.fn(), + mockResolveConfigValues: vi.fn(), mockBuildTaskResult: vi.fn(), mockPersistTaskResult: vi.fn(), mockPersistTaskError: vi.fn(), @@ -39,10 +38,7 @@ vi.mock('../features/tasks/execute/postExecution.js', () => ({ vi.mock('../infra/config/index.js', () => ({ loadPieceByIdentifier: (...args: unknown[]) => mockLoadPieceByIdentifier(...args), isPiecePath: () => false, - loadConfig: () => ({ - global: mockLoadGlobalConfig(), - project: mockLoadProjectConfig(), - }), + resolveConfigValues: (...args: unknown[]) => mockResolveConfigValues(...args), })); vi.mock('../shared/ui/index.js', () => ({ @@ -87,21 +83,19 @@ describe('executeAndCompleteTask', () => { name: 'default', movements: [], }); - mockLoadGlobalConfig.mockReturnValue({ + mockResolveConfigValues.mockReturnValue({ language: 'en', provider: 'claude', + model: undefined, personaProviders: {}, providerProfiles: {}, providerOptions: { claude: { sandbox: { allowUnsandboxedCommands: true } }, }, - }); - mockLoadProjectConfig.mockReturnValue({ - provider: 'claude', - providerProfiles: {}, - providerOptions: { - opencode: { networkAccess: true }, - }, + notificationSound: true, + notificationSoundEvents: {}, + concurrency: 1, + taskPollIntervalMs: 500, }); mockBuildTaskResult.mockReturnValue({ success: true }); mockResolveTaskExecution.mockResolvedValue({ @@ -140,16 +134,12 @@ describe('executeAndCompleteTask', () => { const pieceExecutionOptions = mockExecutePiece.mock.calls[0]?.[3] as { taskDisplayLabel?: string; taskPrefix?: string; - globalProviderOptions?: unknown; - projectProviderOptions?: unknown; + providerOptions?: unknown; }; expect(pieceExecutionOptions?.taskDisplayLabel).toBe(taskDisplayLabel); expect(pieceExecutionOptions?.taskPrefix).toBe(taskDisplayLabel); - expect(pieceExecutionOptions?.globalProviderOptions).toEqual({ + expect(pieceExecutionOptions?.providerOptions).toEqual({ claude: { sandbox: { allowUnsandboxedCommands: true } }, }); - expect(pieceExecutionOptions?.projectProviderOptions).toEqual({ - opencode: { networkAccess: true }, - }); }); }); diff --git a/src/__tests__/taskInstructionActions.test.ts b/src/__tests__/taskInstructionActions.test.ts index 83d12e6..663cd7b 100644 --- a/src/__tests__/taskInstructionActions.test.ts +++ b/src/__tests__/taskInstructionActions.test.ts @@ -48,7 +48,7 @@ vi.mock('../infra/task/index.js', () => ({ })); vi.mock('../infra/config/index.js', () => ({ - loadGlobalConfig: vi.fn(() => ({ interactivePreviewMovements: 3, language: 'en' })), + resolveConfigValues: vi.fn(() => ({ interactivePreviewMovements: 3, language: 'en' })), getPieceDescription: vi.fn(() => ({ name: 'default', description: 'desc', diff --git a/src/__tests__/taskRetryActions.test.ts b/src/__tests__/taskRetryActions.test.ts index b65f54d..46dd579 100644 --- a/src/__tests__/taskRetryActions.test.ts +++ b/src/__tests__/taskRetryActions.test.ts @@ -4,7 +4,7 @@ const { mockExistsSync, mockSelectPiece, mockSelectOption, - mockLoadGlobalConfig, + mockResolveConfigValue, mockLoadPieceByIdentifier, mockGetPieceDescription, mockRunRetryMode, @@ -16,7 +16,7 @@ const { mockExistsSync: vi.fn(() => true), mockSelectPiece: vi.fn(), mockSelectOption: vi.fn(), - mockLoadGlobalConfig: vi.fn(), + mockResolveConfigValue: vi.fn(), mockLoadPieceByIdentifier: vi.fn(), mockGetPieceDescription: vi.fn(() => ({ name: 'default', @@ -60,7 +60,7 @@ vi.mock('../shared/utils/index.js', async (importOriginal) => ({ })); vi.mock('../infra/config/index.js', () => ({ - loadGlobalConfig: (...args: unknown[]) => mockLoadGlobalConfig(...args), + resolveConfigValue: (...args: unknown[]) => mockResolveConfigValue(...args), loadPieceByIdentifier: (...args: unknown[]) => mockLoadPieceByIdentifier(...args), getPieceDescription: (...args: unknown[]) => mockGetPieceDescription(...args), })); @@ -126,7 +126,7 @@ beforeEach(() => { mockExistsSync.mockReturnValue(true); mockSelectPiece.mockResolvedValue('default'); - mockLoadGlobalConfig.mockReturnValue({ defaultPiece: 'default' }); + mockResolveConfigValue.mockReturnValue(3); mockLoadPieceByIdentifier.mockReturnValue(defaultPieceConfig); mockSelectOption.mockResolvedValue('plan'); mockRunRetryMode.mockResolvedValue({ action: 'execute', task: '追加指示A' }); diff --git a/src/agents/runner.ts b/src/agents/runner.ts index bb853c5..e71ac9a 100644 --- a/src/agents/runner.ts +++ b/src/agents/runner.ts @@ -4,7 +4,7 @@ import { existsSync, readFileSync } from 'node:fs'; import { basename, dirname } from 'node:path'; -import { loadCustomAgents, loadAgentPrompt, loadConfig } from '../infra/config/index.js'; +import { loadCustomAgents, loadAgentPrompt, resolveConfigValues } from '../infra/config/index.js'; import { getProvider, type ProviderType, type ProviderCallOptions } from '../infra/providers/index.js'; import type { AgentResponse, CustomAgentConfig } from '../core/models/index.js'; import { createLogger } from '../shared/utils/index.js'; @@ -29,17 +29,10 @@ export class AgentRunner { agentConfig?: CustomAgentConfig, ): ProviderType { if (options?.provider) return options.provider; - const config = loadConfig(cwd); - const projectConfig = config.project; - if (projectConfig.provider) return projectConfig.provider; + const config = resolveConfigValues(cwd, ['provider']); + if (config.provider) return config.provider; if (options?.stepProvider) return options.stepProvider; if (agentConfig?.provider) return agentConfig.provider; - try { - const globalConfig = config.global; - if (globalConfig.provider) return globalConfig.provider; - } catch (error) { - log.debug('Global config not available for provider resolution', { error }); - } return 'claude'; } @@ -57,14 +50,10 @@ export class AgentRunner { if (options?.stepModel) return options.stepModel; if (agentConfig?.model) return agentConfig.model; if (!options?.cwd) return undefined; - try { - const globalConfig = loadConfig(options.cwd).global; - if (globalConfig.model) { - const globalProvider = globalConfig.provider ?? 'claude'; - if (globalProvider === resolvedProvider) return globalConfig.model; - } - } catch (error) { - log.debug('Global config not available for model resolution', { error }); + const config = resolveConfigValues(options.cwd, ['provider', 'model']); + if (config.model) { + const defaultProvider = config.provider ?? 'claude'; + if (defaultProvider === resolvedProvider) return config.model; } return undefined; } @@ -133,7 +122,7 @@ export class AgentRunner { name: agentConfig.name, systemPrompt: agentConfig.claudeAgent || agentConfig.claudeSkill ? undefined - : loadAgentPrompt(agentConfig), + : loadAgentPrompt(agentConfig, options.cwd), claudeAgent: agentConfig.claudeAgent, claudeSkill: agentConfig.claudeSkill, }); diff --git a/src/app/cli/commands.ts b/src/app/cli/commands.ts index 8f336b3..bb1e9a3 100644 --- a/src/app/cli/commands.ts +++ b/src/app/cli/commands.ts @@ -104,7 +104,7 @@ reset .command('categories') .description('Reset piece categories to builtin defaults') .action(async () => { - await resetCategoriesToDefault(); + await resetCategoriesToDefault(resolvedCwd); }); program diff --git a/src/app/cli/program.ts b/src/app/cli/program.ts index 7814cd1..507c470 100644 --- a/src/app/cli/program.ts +++ b/src/app/cli/program.ts @@ -11,7 +11,7 @@ import { resolve } from 'node:path'; import { initGlobalDirs, initProjectDirs, - loadConfig, + resolveConfigValues, isVerboseMode, } from '../../infra/config/index.js'; import { setQuietMode } from '../../shared/context.js'; @@ -69,7 +69,7 @@ export async function runPreActionHook(): Promise { const verbose = isVerboseMode(resolvedCwd); initDebugLogger(verbose ? { enabled: true } : undefined, resolvedCwd); - const { global: config } = loadConfig(resolvedCwd); + const config = resolveConfigValues(resolvedCwd, ['logLevel', 'minimalOutput']); if (verbose) { setVerboseConsole(true); diff --git a/src/app/cli/routing.ts b/src/app/cli/routing.ts index 5d253a1..f6fbbd4 100644 --- a/src/app/cli/routing.ts +++ b/src/app/cli/routing.ts @@ -23,7 +23,7 @@ import { dispatchConversationAction, type InteractiveModeResult, } from '../../features/interactive/index.js'; -import { getPieceDescription, loadConfig } from '../../infra/config/index.js'; +import { getPieceDescription, resolveConfigValues } from '../../infra/config/index.js'; import { DEFAULT_PIECE_NAME } from '../../shared/constants.js'; import { program, resolvedCwd, pipelineMode } from './program.js'; import { resolveAgentOverrides, parseCreateWorktreeOption, isDirectTask } from './helpers.js'; @@ -137,7 +137,7 @@ export async function executeDefaultAction(task?: string): Promise { } // All paths below go through interactive mode - const { global: globalConfig } = loadConfig(resolvedCwd); + const globalConfig = resolveConfigValues(resolvedCwd, ['language', 'interactivePreviewMovements', 'provider']); const lang = resolveLanguage(globalConfig.language); const pieceId = await determinePiece(resolvedCwd, selectOptions.piece); diff --git a/src/core/piece/engine/OptionsBuilder.ts b/src/core/piece/engine/OptionsBuilder.ts index 12f5511..8f99282 100644 --- a/src/core/piece/engine/OptionsBuilder.ts +++ b/src/core/piece/engine/OptionsBuilder.ts @@ -56,9 +56,7 @@ export class OptionsBuilder { const resolvedProviderForPermissions = this.engineOptions.provider - ?? this.engineOptions.projectProvider ?? resolved.provider - ?? this.engineOptions.globalProvider ?? 'claude'; return { @@ -73,12 +71,11 @@ export class OptionsBuilder { movementName: step.name, requiredPermissionMode: step.requiredPermissionMode, provider: resolvedProviderForPermissions, - projectProviderProfiles: this.engineOptions.projectProviderProfiles, - globalProviderProfiles: this.engineOptions.globalProviderProfiles ?? DEFAULT_PROVIDER_PERMISSION_PROFILES, + projectProviderProfiles: this.engineOptions.providerProfiles, + globalProviderProfiles: DEFAULT_PROVIDER_PERMISSION_PROFILES, }), providerOptions: mergeProviderOptions( - this.engineOptions.globalProviderOptions, - this.engineOptions.projectProviderOptions, + this.engineOptions.providerOptions, step.providerOptions, ), language: this.getLanguage(), diff --git a/src/core/piece/types.ts b/src/core/piece/types.ts index ef8c014..f3ae155 100644 --- a/src/core/piece/types.ts +++ b/src/core/piece/types.ts @@ -179,21 +179,13 @@ export interface PieceEngineOptions { /** Language for instruction metadata. Defaults to 'en'. */ language?: Language; provider?: ProviderType; - /** Project config provider (used for provider/profile resolution parity with AgentRunner) */ - projectProvider?: ProviderType; - /** Global config provider (used for provider/profile resolution parity with AgentRunner) */ - globalProvider?: ProviderType; model?: string; - /** Project-level provider options */ - projectProviderOptions?: MovementProviderOptions; - /** Global-level provider options */ - globalProviderOptions?: MovementProviderOptions; + /** Resolved provider options */ + providerOptions?: MovementProviderOptions; /** Per-persona provider overrides (e.g., { coder: 'codex' }) */ personaProviders?: Record; - /** Project-level provider permission profiles */ - projectProviderProfiles?: ProviderPermissionProfiles; - /** Global-level provider permission profiles */ - globalProviderProfiles?: ProviderPermissionProfiles; + /** Resolved provider permission profiles */ + providerProfiles?: ProviderPermissionProfiles; /** Enable interactive-only rules and user-input transitions */ interactive?: boolean; /** Rule tag index detector (required for rules evaluation) */ diff --git a/src/features/catalog/catalogFacets.ts b/src/features/catalog/catalogFacets.ts index 5a37a43..5f781c5 100644 --- a/src/features/catalog/catalogFacets.ts +++ b/src/features/catalog/catalogFacets.ts @@ -11,7 +11,7 @@ import chalk from 'chalk'; import type { PieceSource } from '../../infra/config/loaders/pieceResolver.js'; import { getLanguageResourcesDir } from '../../infra/resources/index.js'; import { getGlobalConfigDir, getProjectConfigDir } from '../../infra/config/paths.js'; -import { getLanguage, getBuiltinPiecesEnabled } from '../../infra/config/global/globalConfig.js'; +import { resolveConfigValues } from '../../infra/config/index.js'; import { section, error as logError, info } from '../../shared/ui/index.js'; const FACET_TYPES = [ @@ -62,10 +62,11 @@ function getFacetDirs( facetType: FacetType, cwd: string, ): { dir: string; source: PieceSource }[] { + const config = resolveConfigValues(cwd, ['enableBuiltinPieces', 'language']); const dirs: { dir: string; source: PieceSource }[] = []; - if (getBuiltinPiecesEnabled()) { - const lang = getLanguage(); + if (config.enableBuiltinPieces !== false) { + const lang = config.language; dirs.push({ dir: join(getLanguageResourcesDir(lang), facetType), source: 'builtin' }); } diff --git a/src/features/config/resetCategories.ts b/src/features/config/resetCategories.ts index 369d9cf..ff3d494 100644 --- a/src/features/config/resetCategories.ts +++ b/src/features/config/resetCategories.ts @@ -5,12 +5,12 @@ import { resetPieceCategories, getPieceCategoriesPath } from '../../infra/config/global/pieceCategories.js'; import { header, success, info } from '../../shared/ui/index.js'; -export async function resetCategoriesToDefault(): Promise { +export async function resetCategoriesToDefault(cwd: string): Promise { header('Reset Categories'); - resetPieceCategories(); + resetPieceCategories(cwd); - const userPath = getPieceCategoriesPath(); + const userPath = getPieceCategoriesPath(cwd); success('User category overlay reset.'); info(` ${userPath}`); } diff --git a/src/features/interactive/conversationLoop.ts b/src/features/interactive/conversationLoop.ts index 5baf581..dd086c8 100644 --- a/src/features/interactive/conversationLoop.ts +++ b/src/features/interactive/conversationLoop.ts @@ -10,7 +10,7 @@ import chalk from 'chalk'; import { - loadConfig, + resolveConfigValues, loadPersonaSessions, updatePersonaSession, loadSessionState, @@ -58,7 +58,7 @@ export interface SessionContext { * Initialize provider, session, and language for interactive conversation. */ export function initializeSession(cwd: string, personaName: string): SessionContext { - const { global: globalConfig } = loadConfig(cwd); + const globalConfig = resolveConfigValues(cwd, ['language', 'provider', 'model']); const lang = resolveLanguage(globalConfig.language); if (!globalConfig.provider) { throw new Error('Provider is not configured.'); diff --git a/src/features/interactive/retryMode.ts b/src/features/interactive/retryMode.ts index a3a66da..7f10f98 100644 --- a/src/features/interactive/retryMode.ts +++ b/src/features/interactive/retryMode.ts @@ -22,7 +22,7 @@ import { import { resolveLanguage } from './interactive.js'; import { loadTemplate } from '../../shared/prompts/index.js'; import { getLabelObject } from '../../shared/i18n/index.js'; -import { loadConfig } from '../../infra/config/index.js'; +import { resolveConfigValues } from '../../infra/config/index.js'; import type { InstructModeResult, InstructUIText } from '../tasks/list/instructMode.js'; /** Failure information for a retry task */ @@ -116,7 +116,7 @@ export async function runRetryMode( cwd: string, retryContext: RetryContext, ): Promise { - const { global: globalConfig } = loadConfig(cwd); + const globalConfig = resolveConfigValues(cwd, ['language', 'provider']); const lang = resolveLanguage(globalConfig.language); if (!globalConfig.provider) { diff --git a/src/features/pieceSelection/index.ts b/src/features/pieceSelection/index.ts index 67cfa98..f90f85c 100644 --- a/src/features/pieceSelection/index.ts +++ b/src/features/pieceSelection/index.ts @@ -521,7 +521,7 @@ export async function selectPiece( options?: SelectPieceOptions, ): Promise { const fallbackToDefault = options?.fallbackToDefault !== false; - const categoryConfig = getPieceCategories(); + const categoryConfig = getPieceCategories(cwd); const currentPiece = getCurrentPiece(cwd); if (categoryConfig) { @@ -534,7 +534,7 @@ export async function selectPiece( info('No pieces found.'); return null; } - const categorized = buildCategorizedPieces(allPieces, categoryConfig); + const categorized = buildCategorizedPieces(allPieces, categoryConfig, cwd); warnMissingPieces(categorized.missingPieces.filter((missing) => missing.source === 'user')); return selectPieceFromCategorizedPieces(categorized, currentPiece); } diff --git a/src/features/pipeline/execute.ts b/src/features/pipeline/execute.ts index 0542a22..385e0f3 100644 --- a/src/features/pipeline/execute.ts +++ b/src/features/pipeline/execute.ts @@ -21,7 +21,7 @@ import { } from '../../infra/github/index.js'; import { stageAndCommit, getCurrentBranch } from '../../infra/task/index.js'; import { executeTask, type TaskExecutionOptions, type PipelineExecutionOptions } from '../tasks/index.js'; -import { loadConfig } from '../../infra/config/index.js'; +import { resolveConfigValues } from '../../infra/config/index.js'; import { info, error, success, status, blankLine } from '../../shared/ui/index.js'; import { createLogger, getErrorMessage } from '../../shared/utils/index.js'; import type { PipelineConfig } from '../../core/models/index.js'; @@ -106,7 +106,7 @@ function buildPipelinePrBody( */ export async function executePipeline(options: PipelineExecutionOptions): Promise { const { cwd, piece, autoPr, skipGit } = options; - const { global: globalConfig } = loadConfig(cwd); + const globalConfig = resolveConfigValues(cwd, ['pipeline']); const pipelineConfig = globalConfig.pipeline; let issue: GitHubIssue | undefined; let task: string; diff --git a/src/features/prompt/preview.ts b/src/features/prompt/preview.ts index 5264262..9b540ba 100644 --- a/src/features/prompt/preview.ts +++ b/src/features/prompt/preview.ts @@ -5,7 +5,7 @@ * Useful for debugging and understanding what prompts agents will receive. */ -import { loadPieceByIdentifier, getCurrentPiece, loadConfig } from '../../infra/config/index.js'; +import { loadPieceByIdentifier, getCurrentPiece, resolveConfigValue } from '../../infra/config/index.js'; import { InstructionBuilder } from '../../core/piece/instruction/InstructionBuilder.js'; import { ReportInstructionBuilder } from '../../core/piece/instruction/ReportInstructionBuilder.js'; import { StatusJudgmentBuilder } from '../../core/piece/instruction/StatusJudgmentBuilder.js'; @@ -29,8 +29,7 @@ export async function previewPrompts(cwd: string, pieceIdentifier?: string): Pro return; } - const { global: globalConfig } = loadConfig(cwd); - const language: Language = globalConfig.language ?? 'en'; + const language = resolveConfigValue(cwd, 'language') as Language; header(`Prompt Preview: ${config.name}`); info(`Movements: ${config.movements.length}`); diff --git a/src/features/tasks/execute/pieceExecution.ts b/src/features/tasks/execute/pieceExecution.ts index 584e4d4..bacbc07 100644 --- a/src/features/tasks/execute/pieceExecution.ts +++ b/src/features/tasks/execute/pieceExecution.ts @@ -17,7 +17,7 @@ import { updatePersonaSession, loadWorktreeSessions, updateWorktreeSession, - loadConfig, + resolveConfigValues, saveSessionState, type SessionState, } from '../../../infra/config/index.js'; @@ -317,7 +317,10 @@ export async function executePiece( // Load saved agent sessions only on retry; normal runs start with empty sessions const isWorktree = cwd !== projectCwd; - const { global: globalConfig } = loadConfig(projectCwd); + const globalConfig = resolveConfigValues( + projectCwd, + ['notificationSound', 'notificationSoundEvents', 'provider', 'runtime', 'preventSleep', 'model', 'observability'], + ); const shouldNotify = globalConfig.notificationSound !== false; const notificationSoundEvents = globalConfig.notificationSoundEvents; const shouldNotifyIterationLimit = shouldNotify && notificationSoundEvents?.iterationLimit !== false; @@ -443,14 +446,10 @@ export async function executePiece( projectCwd, language: options.language, provider: options.provider, - projectProvider: options.projectProvider, - globalProvider: options.globalProvider, model: options.model, - projectProviderOptions: options.projectProviderOptions, - globalProviderOptions: options.globalProviderOptions, + providerOptions: options.providerOptions, personaProviders: options.personaProviders, - projectProviderProfiles: options.projectProviderProfiles, - globalProviderProfiles: options.globalProviderProfiles, + providerProfiles: options.providerProfiles, interactive: interactiveUserInput, detectRuleIndex, callAiJudge, diff --git a/src/features/tasks/execute/postExecution.ts b/src/features/tasks/execute/postExecution.ts index 857d459..b29d18d 100644 --- a/src/features/tasks/execute/postExecution.ts +++ b/src/features/tasks/execute/postExecution.ts @@ -5,7 +5,7 @@ * instructBranch (instruct mode from takt list). */ -import { loadConfig } from '../../../infra/config/index.js'; +import { resolveConfigValue } from '../../../infra/config/index.js'; import { confirm } from '../../../shared/prompt/index.js'; import { autoCommitAndPush } from '../../../infra/task/index.js'; import { info, error, success } from '../../../shared/ui/index.js'; @@ -23,9 +23,9 @@ export async function resolveAutoPr(optionAutoPr: boolean | undefined, cwd: stri return optionAutoPr; } - const { global: globalConfig } = loadConfig(cwd); - if (typeof globalConfig.autoPr === 'boolean') { - return globalConfig.autoPr; + const autoPr = resolveConfigValue(cwd, 'autoPr'); + if (typeof autoPr === 'boolean') { + return autoPr; } return confirm('Create pull request?', true); diff --git a/src/features/tasks/execute/resolveTask.ts b/src/features/tasks/execute/resolveTask.ts index e7d0c2e..799b7ce 100644 --- a/src/features/tasks/execute/resolveTask.ts +++ b/src/features/tasks/execute/resolveTask.ts @@ -4,7 +4,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; -import { loadConfig } from '../../../infra/config/index.js'; +import { resolveConfigValue } from '../../../infra/config/index.js'; import { type TaskInfo, createSharedClone, summarizeTaskName, getCurrentBranch } from '../../../infra/task/index.js'; import { withProgress } from '../../../shared/ui/index.js'; import { getTaskSlugFromTaskDir } from '../../../shared/utils/taskPaths.js'; @@ -141,8 +141,7 @@ export async function resolveTaskExecution( if (data.auto_pr !== undefined) { autoPr = data.auto_pr; } else { - const { global: globalConfig } = loadConfig(defaultCwd); - autoPr = globalConfig.autoPr ?? false; + autoPr = resolveConfigValue(defaultCwd, 'autoPr') ?? false; } return { diff --git a/src/features/tasks/execute/session.ts b/src/features/tasks/execute/session.ts index 9303f65..d762978 100644 --- a/src/features/tasks/execute/session.ts +++ b/src/features/tasks/execute/session.ts @@ -2,7 +2,7 @@ * Session management helpers for agent execution */ -import { loadPersonaSessions, updatePersonaSession, loadConfig } from '../../../infra/config/index.js'; +import { loadPersonaSessions, updatePersonaSession, resolveConfigValue } from '../../../infra/config/index.js'; import type { AgentResponse } from '../../../core/models/index.js'; /** @@ -15,7 +15,7 @@ export async function withPersonaSession( fn: (sessionId?: string) => Promise, provider?: string ): Promise { - const resolvedProvider = provider ?? loadConfig(cwd).global.provider ?? 'claude'; + const resolvedProvider = provider ?? resolveConfigValue(cwd, 'provider') ?? 'claude'; const sessions = loadPersonaSessions(cwd, resolvedProvider); const sessionId = sessions[personaName]; diff --git a/src/features/tasks/execute/taskExecution.ts b/src/features/tasks/execute/taskExecution.ts index a556fed..79ba68f 100644 --- a/src/features/tasks/execute/taskExecution.ts +++ b/src/features/tasks/execute/taskExecution.ts @@ -2,7 +2,7 @@ * Task execution logic */ -import { loadPieceByIdentifier, isPiecePath, loadConfig } from '../../../infra/config/index.js'; +import { loadPieceByIdentifier, isPiecePath, resolveConfigValues } from '../../../infra/config/index.js'; import { TaskRunner, type TaskInfo } from '../../../infra/task/index.js'; import { header, @@ -86,21 +86,22 @@ async function executeTaskWithResult(options: ExecuteTaskOptions): Promise s.name), }); - const config = loadConfig(projectCwd); - const globalConfig = config.global; - const projectConfig = config.project; + const config = resolveConfigValues(projectCwd, [ + 'language', + 'provider', + 'model', + 'providerOptions', + 'personaProviders', + 'providerProfiles', + ]); return await executePiece(pieceConfig, task, cwd, { projectCwd, - language: globalConfig.language, - provider: agentOverrides?.provider, - projectProvider: projectConfig.provider, - globalProvider: globalConfig.provider, - model: agentOverrides?.model, - projectProviderOptions: projectConfig.providerOptions, - globalProviderOptions: globalConfig.providerOptions, - personaProviders: globalConfig.personaProviders, - projectProviderProfiles: projectConfig.providerProfiles, - globalProviderProfiles: globalConfig.providerProfiles, + language: config.language, + provider: agentOverrides?.provider ?? config.provider, + model: agentOverrides?.model ?? config.model, + providerOptions: config.providerOptions, + personaProviders: config.personaProviders, + providerProfiles: config.providerProfiles, interactiveUserInput, interactiveMetadata, startMovement, @@ -237,7 +238,10 @@ export async function runAllTasks( options?: TaskExecutionOptions, ): Promise { const taskRunner = new TaskRunner(cwd); - const { global: globalConfig } = loadConfig(cwd); + const globalConfig = resolveConfigValues( + cwd, + ['notificationSound', 'notificationSoundEvents', 'concurrency', 'taskPollIntervalMs'], + ); const shouldNotifyRunComplete = globalConfig.notificationSound !== false && globalConfig.notificationSoundEvents?.runComplete !== false; const shouldNotifyRunAbort = globalConfig.notificationSound !== false diff --git a/src/features/tasks/execute/types.ts b/src/features/tasks/execute/types.ts index 7fd2f06..30f07de 100644 --- a/src/features/tasks/execute/types.ts +++ b/src/features/tasks/execute/types.ts @@ -33,21 +33,13 @@ export interface PieceExecutionOptions { /** Language for instruction metadata */ language?: Language; provider?: ProviderType; - /** Project config provider */ - projectProvider?: ProviderType; - /** Global config provider */ - globalProvider?: ProviderType; model?: string; - /** Project-level provider options */ - projectProviderOptions?: MovementProviderOptions; - /** Global-level provider options */ - globalProviderOptions?: MovementProviderOptions; + /** Resolved provider options */ + providerOptions?: MovementProviderOptions; /** Per-persona provider overrides (e.g., { coder: 'codex' }) */ personaProviders?: Record; - /** Project-level provider permission profiles */ - projectProviderProfiles?: ProviderPermissionProfiles; - /** Global-level provider permission profiles */ - globalProviderProfiles?: ProviderPermissionProfiles; + /** Resolved provider permission profiles */ + providerProfiles?: ProviderPermissionProfiles; /** Enable interactive user input during step transitions */ interactiveUserInput?: boolean; /** Interactive mode result metadata for NDJSON logging */ diff --git a/src/features/tasks/list/instructMode.ts b/src/features/tasks/list/instructMode.ts index 5bb0f5a..1bd3476 100644 --- a/src/features/tasks/list/instructMode.ts +++ b/src/features/tasks/list/instructMode.ts @@ -23,7 +23,7 @@ import { import { type RunSessionContext, formatRunSessionForPrompt } from '../../interactive/runSessionReader.js'; import { loadTemplate } from '../../../shared/prompts/index.js'; import { getLabelObject } from '../../../shared/i18n/index.js'; -import { loadConfig } from '../../../infra/config/index.js'; +import { resolveConfigValues } from '../../../infra/config/index.js'; export type InstructModeAction = 'execute' | 'save_task' | 'cancel'; @@ -109,7 +109,7 @@ export async function runInstructMode( pieceContext?: PieceContext, runSessionContext?: RunSessionContext, ): Promise { - const { global: globalConfig } = loadConfig(cwd); + const globalConfig = resolveConfigValues(cwd, ['language', 'provider']); const lang = resolveLanguage(globalConfig.language); if (!globalConfig.provider) { diff --git a/src/features/tasks/list/taskInstructionActions.ts b/src/features/tasks/list/taskInstructionActions.ts index b520bc6..b2d3668 100644 --- a/src/features/tasks/list/taskInstructionActions.ts +++ b/src/features/tasks/list/taskInstructionActions.ts @@ -11,7 +11,7 @@ import { TaskRunner, detectDefaultBranch, } from '../../../infra/task/index.js'; -import { loadConfig, getPieceDescription } from '../../../infra/config/index.js'; +import { resolveConfigValues, getPieceDescription } from '../../../infra/config/index.js'; import { info, error as logError } from '../../../shared/ui/index.js'; import { createLogger, getErrorMessage } from '../../../shared/utils/index.js'; import { runInstructMode } from './instructMode.js'; @@ -93,7 +93,7 @@ export async function instructBranch( return false; } - const { global: globalConfig } = loadConfig(projectDir); + const globalConfig = resolveConfigValues(projectDir, ['interactivePreviewMovements', 'language']); const pieceDesc = getPieceDescription(selectedPiece, projectDir, globalConfig.interactivePreviewMovements); const pieceContext: PieceContext = { name: pieceDesc.name, diff --git a/src/features/tasks/list/taskRetryActions.ts b/src/features/tasks/list/taskRetryActions.ts index 9f36876..2a68bd5 100644 --- a/src/features/tasks/list/taskRetryActions.ts +++ b/src/features/tasks/list/taskRetryActions.ts @@ -8,7 +8,7 @@ import * as fs from 'node:fs'; import type { TaskListItem } from '../../../infra/task/index.js'; import { TaskRunner } from '../../../infra/task/index.js'; -import { loadPieceByIdentifier, loadConfig, getPieceDescription } from '../../../infra/config/index.js'; +import { loadPieceByIdentifier, resolveConfigValue, getPieceDescription } from '../../../infra/config/index.js'; import { selectPiece } from '../../pieceSelection/index.js'; import { selectOption } from '../../../shared/prompt/index.js'; import { info, header, blankLine, status } from '../../../shared/ui/index.js'; @@ -133,7 +133,7 @@ export async function retryFailedTask( return false; } - const { global: globalConfig } = loadConfig(projectDir); + const previewCount = resolveConfigValue(projectDir, 'interactivePreviewMovements'); const pieceConfig = loadPieceByIdentifier(selectedPiece, projectDir); if (!pieceConfig) { @@ -145,7 +145,7 @@ export async function retryFailedTask( return false; } - const pieceDesc = getPieceDescription(selectedPiece, projectDir, globalConfig.interactivePreviewMovements); + const pieceDesc = getPieceDescription(selectedPiece, projectDir, previewCount); const pieceContext = { name: pieceDesc.name, description: pieceDesc.description, diff --git a/src/infra/config/global/pieceCategories.ts b/src/infra/config/global/pieceCategories.ts index b189ab1..b0e50e1 100644 --- a/src/infra/config/global/pieceCategories.ts +++ b/src/infra/config/global/pieceCategories.ts @@ -7,7 +7,7 @@ import { existsSync, mkdirSync, writeFileSync } from 'node:fs'; import { dirname, join } from 'node:path'; import { getGlobalConfigDir } from '../paths.js'; -import { loadGlobalConfig } from './globalConfig.js'; +import { loadConfig } from '../loadConfig.js'; const INITIAL_USER_CATEGORIES_CONTENT = 'piece_categories: {}\n'; @@ -16,8 +16,8 @@ function getDefaultPieceCategoriesPath(): string { } /** Get the path to the user's piece categories file. */ -export function getPieceCategoriesPath(): string { - const config = loadGlobalConfig(); +export function getPieceCategoriesPath(cwd: string): string { + const config = loadConfig(cwd); if (config.pieceCategoriesFile) { return config.pieceCategoriesFile; } @@ -27,8 +27,8 @@ export function getPieceCategoriesPath(): string { /** * Reset user categories overlay file to initial content. */ -export function resetPieceCategories(): void { - const userPath = getPieceCategoriesPath(); +export function resetPieceCategories(cwd: string): void { + const userPath = getPieceCategoriesPath(cwd); const dir = dirname(userPath); if (!existsSync(dir)) { mkdirSync(dir, { recursive: true }); diff --git a/src/infra/config/index.ts b/src/infra/config/index.ts index 0045bfe..585a844 100644 --- a/src/infra/config/index.ts +++ b/src/infra/config/index.ts @@ -6,4 +6,4 @@ export * from './paths.js'; export * from './loaders/index.js'; export * from './global/index.js'; export * from './project/index.js'; -export * from './loadConfig.js'; +export * from './resolveConfigValue.js'; diff --git a/src/infra/config/loadConfig.ts b/src/infra/config/loadConfig.ts index 85edeff..088b540 100644 --- a/src/infra/config/loadConfig.ts +++ b/src/infra/config/loadConfig.ts @@ -1,16 +1,108 @@ import type { GlobalConfig } from '../../core/models/index.js'; -import type { ProjectLocalConfig } from './project/projectConfig.js'; +import type { MovementProviderOptions } from '../../core/models/piece-types.js'; +import type { ProviderPermissionProfiles } from '../../core/models/provider-profiles.js'; import { loadGlobalConfig } from './global/globalConfig.js'; import { loadProjectConfig } from './project/projectConfig.js'; +import { envVarNameFromPath } from './env/config-env-overrides.js'; -export interface LoadedConfig { - global: GlobalConfig; - project: ProjectLocalConfig; +export interface LoadedConfig extends GlobalConfig { + piece: string; + verbose: boolean; + providerOptions?: MovementProviderOptions; + providerProfiles?: ProviderPermissionProfiles; } export function loadConfig(projectDir: string): LoadedConfig { + const global = loadGlobalConfig(); + const project = loadProjectConfig(projectDir); + const provider = project.provider ?? global.provider; + return { - global: loadGlobalConfig(), - project: loadProjectConfig(projectDir), + ...global, + piece: project.piece ?? 'default', + provider, + model: resolveModel(global, provider), + verbose: resolveVerbose(project.verbose, global.verbose), + providerOptions: mergeProviderOptions(global.providerOptions, project.providerOptions), + providerProfiles: mergeProviderProfiles(global.providerProfiles, project.providerProfiles), }; } + +function resolveModel(global: GlobalConfig, provider: GlobalConfig['provider']): string | undefined { + if (!global.model) return undefined; + const globalProvider = global.provider ?? 'claude'; + const resolvedProvider = provider ?? 'claude'; + if (globalProvider !== resolvedProvider) return undefined; + return global.model; +} + +function resolveVerbose(projectVerbose: boolean | undefined, globalVerbose: boolean | undefined): boolean { + const envVerbose = loadEnvBooleanSetting('verbose'); + if (envVerbose !== undefined) return envVerbose; + if (projectVerbose !== undefined) return projectVerbose; + if (globalVerbose !== undefined) return globalVerbose; + return false; +} + +function loadEnvBooleanSetting(configKey: string): boolean | undefined { + const envKey = envVarNameFromPath(configKey); + const raw = process.env[envKey]; + if (raw === undefined) return undefined; + + const normalized = raw.trim().toLowerCase(); + if (normalized === 'true') return true; + if (normalized === 'false') return false; + + throw new Error(`${envKey} must be one of: true, false`); +} + +function mergeProviderOptions( + globalOptions: MovementProviderOptions | undefined, + projectOptions: MovementProviderOptions | undefined, +): MovementProviderOptions | undefined { + if (!globalOptions && !projectOptions) return undefined; + + const result: MovementProviderOptions = {}; + if (globalOptions?.codex || projectOptions?.codex) { + result.codex = { ...globalOptions?.codex, ...projectOptions?.codex }; + } + if (globalOptions?.opencode || projectOptions?.opencode) { + result.opencode = { ...globalOptions?.opencode, ...projectOptions?.opencode }; + } + if (globalOptions?.claude?.sandbox || projectOptions?.claude?.sandbox) { + result.claude = { + sandbox: { + ...globalOptions?.claude?.sandbox, + ...projectOptions?.claude?.sandbox, + }, + }; + } + + return Object.keys(result).length > 0 ? result : undefined; +} + +function mergeProviderProfiles( + globalProfiles: ProviderPermissionProfiles | undefined, + projectProfiles: ProviderPermissionProfiles | undefined, +): ProviderPermissionProfiles | undefined { + if (!globalProfiles && !projectProfiles) return undefined; + + const merged: ProviderPermissionProfiles = { ...(globalProfiles ?? {}) }; + for (const [provider, profile] of Object.entries(projectProfiles ?? {})) { + const key = provider as keyof ProviderPermissionProfiles; + const existing = merged[key]; + if (!existing) { + merged[key] = profile; + continue; + } + merged[key] = { + defaultPermissionMode: profile.defaultPermissionMode, + movementPermissionOverrides: { + ...(existing.movementPermissionOverrides ?? {}), + ...(profile.movementPermissionOverrides ?? {}), + }, + }; + } + + return Object.keys(merged).length > 0 ? merged : undefined; +} diff --git a/src/infra/config/loaders/agentLoader.ts b/src/infra/config/loaders/agentLoader.ts index 483d690..8af69cf 100644 --- a/src/infra/config/loaders/agentLoader.ts +++ b/src/infra/config/loaders/agentLoader.ts @@ -16,11 +16,11 @@ import { getBuiltinPiecesDir, isPathSafe, } from '../paths.js'; -import { getLanguage } from '../global/globalConfig.js'; +import { loadConfig } from '../loadConfig.js'; /** Get all allowed base directories for persona prompt files */ -function getAllowedPromptBases(): string[] { - const lang = getLanguage(); +function getAllowedPromptBases(cwd: string): string[] { + const lang = loadConfig(cwd).language; return [ getGlobalPersonasDir(), getGlobalPiecesDir(), @@ -63,14 +63,14 @@ export function listCustomAgents(): string[] { } /** Load agent prompt content. */ -export function loadAgentPrompt(agent: CustomAgentConfig): string { +export function loadAgentPrompt(agent: CustomAgentConfig, cwd: string): string { if (agent.prompt) { return agent.prompt; } if (agent.promptFile) { const promptFile = agent.promptFile; - const isValid = getAllowedPromptBases().some((base) => isPathSafe(base, promptFile)); + const isValid = getAllowedPromptBases(cwd).some((base) => isPathSafe(base, promptFile)); if (!isValid) { throw new Error(`Agent prompt file path is not allowed: ${agent.promptFile}`); } @@ -86,8 +86,8 @@ export function loadAgentPrompt(agent: CustomAgentConfig): string { } /** Load persona prompt from a resolved path. */ -export function loadPersonaPromptFromPath(personaPath: string): string { - const isValid = getAllowedPromptBases().some((base) => isPathSafe(base, personaPath)); +export function loadPersonaPromptFromPath(personaPath: string, cwd: string): string { + const isValid = getAllowedPromptBases(cwd).some((base) => isPathSafe(base, personaPath)); if (!isValid) { throw new Error(`Persona prompt file path is not allowed: ${personaPath}`); } diff --git a/src/infra/config/loaders/pieceCategories.ts b/src/infra/config/loaders/pieceCategories.ts index 6bbd64b..135cdc3 100644 --- a/src/infra/config/loaders/pieceCategories.ts +++ b/src/infra/config/loaders/pieceCategories.ts @@ -10,10 +10,10 @@ import { existsSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; import { parse as parseYaml } from 'yaml'; import { z } from 'zod/v4'; -import { getLanguage, getBuiltinPiecesEnabled, getDisabledBuiltins } from '../global/globalConfig.js'; import { getPieceCategoriesPath } from '../global/pieceCategories.js'; import { getLanguageResourcesDir } from '../../resources/index.js'; import { listBuiltinPieceNames } from './pieceResolver.js'; +import { loadConfig } from '../loadConfig.js'; import type { PieceWithSource } from './pieceResolver.js'; const CategoryConfigSchema = z.object({ @@ -232,8 +232,8 @@ function resolveOthersCategoryName(defaultConfig: ParsedCategoryConfig, userConf * Load default categories from builtin resource file. * Returns null if file doesn't exist or has no piece_categories. */ -export function loadDefaultCategories(): CategoryConfig | null { - const lang = getLanguage(); +export function loadDefaultCategories(cwd: string): CategoryConfig | null { + const lang = loadConfig(cwd).language; const filePath = join(getLanguageResourcesDir(lang), 'piece-categories.yaml'); const parsed = loadCategoryConfigFromPath(filePath, filePath); @@ -255,8 +255,8 @@ export function loadDefaultCategories(): CategoryConfig | null { } /** Get the path to the builtin default categories file. */ -export function getDefaultCategoriesPath(): string { - const lang = getLanguage(); +export function getDefaultCategoriesPath(cwd: string): string { + const lang = loadConfig(cwd).language; return join(getLanguageResourcesDir(lang), 'piece-categories.yaml'); } @@ -264,14 +264,14 @@ export function getDefaultCategoriesPath(): string { * Get effective piece categories configuration. * Built from builtin categories and optional user overlay. */ -export function getPieceCategories(): CategoryConfig | null { - const defaultPath = getDefaultCategoriesPath(); +export function getPieceCategories(cwd: string): CategoryConfig | null { + const defaultPath = getDefaultCategoriesPath(cwd); const defaultConfig = loadCategoryConfigFromPath(defaultPath, defaultPath); if (!defaultConfig?.pieceCategories) { return null; } - const userPath = getPieceCategoriesPath(); + const userPath = getPieceCategoriesPath(cwd); const userConfig = loadCategoryConfigFromPath(userPath, userPath); const merged = userConfig?.pieceCategories @@ -376,14 +376,16 @@ function appendOthersCategory( export function buildCategorizedPieces( allPieces: Map, config: CategoryConfig, + cwd: string, ): CategorizedPieces { + const globalConfig = loadConfig(cwd); const ignoreMissing = new Set(); - if (!getBuiltinPiecesEnabled()) { - for (const name of listBuiltinPieceNames({ includeDisabled: true })) { + if (globalConfig.enableBuiltinPieces === false) { + for (const name of listBuiltinPieceNames(cwd, { includeDisabled: true })) { ignoreMissing.add(name); } } else { - for (const name of getDisabledBuiltins()) { + for (const name of (globalConfig.disabledBuiltins ?? [])) { ignoreMissing.add(name); } } diff --git a/src/infra/config/loaders/pieceParser.ts b/src/infra/config/loaders/pieceParser.ts index cf39b40..12cfdbf 100644 --- a/src/infra/config/loaders/pieceParser.ts +++ b/src/infra/config/loaders/pieceParser.ts @@ -11,7 +11,7 @@ import { parse as parseYaml } from 'yaml'; import type { z } from 'zod'; import { PieceConfigRawSchema, PieceMovementRawSchema } from '../../../core/models/index.js'; import type { PieceConfig, PieceMovement, PieceRule, OutputContractEntry, OutputContractItem, LoopMonitorConfig, LoopMonitorJudge, ArpeggioMovementConfig, ArpeggioMergeMovementConfig, TeamLeaderConfig } from '../../../core/models/index.js'; -import { getLanguage } from '../global/globalConfig.js'; +import { loadConfig } from '../loadConfig.js'; import { type PieceSections, type FacetResolutionContext, @@ -428,9 +428,9 @@ export function normalizePieceConfig( /** * Load a piece from a YAML file. * @param filePath Path to the piece YAML file - * @param projectDir Optional project directory for 3-layer facet resolution + * @param projectDir Project directory for 3-layer facet resolution */ -export function loadPieceFromFile(filePath: string, projectDir?: string): PieceConfig { +export function loadPieceFromFile(filePath: string, projectDir: string): PieceConfig { if (!existsSync(filePath)) { throw new Error(`Piece file not found: ${filePath}`); } @@ -439,7 +439,7 @@ export function loadPieceFromFile(filePath: string, projectDir?: string): PieceC const pieceDir = dirname(filePath); const context: FacetResolutionContext = { - lang: getLanguage(), + lang: loadConfig(projectDir).language, projectDir, }; diff --git a/src/infra/config/loaders/pieceResolver.ts b/src/infra/config/loaders/pieceResolver.ts index 7a60f9d..fa4059f 100644 --- a/src/infra/config/loaders/pieceResolver.ts +++ b/src/infra/config/loaders/pieceResolver.ts @@ -10,7 +10,7 @@ import { join, resolve, isAbsolute } from 'node:path'; import { homedir } from 'node:os'; import type { PieceConfig, PieceMovement, InteractiveMode } from '../../../core/models/index.js'; import { getGlobalPiecesDir, getBuiltinPiecesDir, getProjectConfigDir } from '../paths.js'; -import { getLanguage, getDisabledBuiltins, getBuiltinPiecesEnabled } from '../global/globalConfig.js'; +import { loadConfig } from '../loadConfig.js'; import { createLogger, getErrorMessage } from '../../../shared/utils/index.js'; import { loadPieceFromFile } from './pieceParser.js'; @@ -23,10 +23,11 @@ export interface PieceWithSource { source: PieceSource; } -export function listBuiltinPieceNames(options?: { includeDisabled?: boolean }): string[] { - const lang = getLanguage(); +export function listBuiltinPieceNames(cwd: string, options?: { includeDisabled?: boolean }): string[] { + const config = loadConfig(cwd); + const lang = config.language; const dir = getBuiltinPiecesDir(lang); - const disabled = options?.includeDisabled ? undefined : getDisabledBuiltins(); + const disabled = options?.includeDisabled ? undefined : (config.disabledBuiltins ?? []); const names = new Set(); for (const entry of iteratePieceDir(dir, 'builtin', disabled)) { names.add(entry.name); @@ -35,10 +36,11 @@ export function listBuiltinPieceNames(options?: { includeDisabled?: boolean }): } /** Get builtin piece by name */ -export function getBuiltinPiece(name: string, projectCwd?: string): PieceConfig | null { - if (!getBuiltinPiecesEnabled()) return null; - const lang = getLanguage(); - const disabled = getDisabledBuiltins(); +export function getBuiltinPiece(name: string, projectCwd: string): PieceConfig | null { + const config = loadConfig(projectCwd); + if (config.enableBuiltinPieces === false) return null; + const lang = config.language; + const disabled = config.disabledBuiltins ?? []; if (disabled.includes(name)) return null; const builtinDir = getBuiltinPiecesDir(lang); @@ -69,7 +71,7 @@ function resolvePath(pathInput: string, basePath: string): string { function loadPieceFromPath( filePath: string, basePath: string, - projectCwd?: string, + projectCwd: string, ): PieceConfig | null { const resolvedPath = resolvePath(filePath, basePath); if (!existsSync(resolvedPath)) { @@ -371,10 +373,11 @@ function* iteratePieceDir( /** Get the 3-layer directory list (builtin → user → project-local) */ function getPieceDirs(cwd: string): { dir: string; source: PieceSource; disabled?: string[] }[] { - const disabled = getDisabledBuiltins(); - const lang = getLanguage(); + const config = loadConfig(cwd); + const disabled = config.disabledBuiltins ?? []; + const lang = config.language; const dirs: { dir: string; source: PieceSource; disabled?: string[] }[] = []; - if (getBuiltinPiecesEnabled()) { + if (config.enableBuiltinPieces !== false) { dirs.push({ dir: getBuiltinPiecesDir(lang), disabled, source: 'builtin' }); } dirs.push({ dir: getGlobalPiecesDir(), source: 'user' }); diff --git a/src/infra/config/project/resolvedSettings.ts b/src/infra/config/project/resolvedSettings.ts index f67fc71..e514c5d 100644 --- a/src/infra/config/project/resolvedSettings.ts +++ b/src/infra/config/project/resolvedSettings.ts @@ -27,6 +27,6 @@ function loadEnvBooleanSetting(configKey: string): boolean | undefined { export function isVerboseMode(projectDir: string): boolean { const envValue = loadEnvBooleanSetting('verbose'); - const { project, global } = loadConfig(projectDir); - return resolveValue(envValue, project.verbose, global.verbose, false); + const config = loadConfig(projectDir); + return resolveValue(envValue, undefined, config.verbose, false); } diff --git a/src/infra/config/resolveConfigValue.ts b/src/infra/config/resolveConfigValue.ts new file mode 100644 index 0000000..8f7d4f9 --- /dev/null +++ b/src/infra/config/resolveConfigValue.ts @@ -0,0 +1,22 @@ +import { loadConfig, type LoadedConfig } from './loadConfig.js'; + +export type ConfigParameterKey = keyof LoadedConfig; + +export function resolveConfigValue( + projectDir: string, + key: K, +): LoadedConfig[K] { + return loadConfig(projectDir)[key]; +} + +export function resolveConfigValues( + projectDir: string, + keys: readonly K[], +): Pick { + const config = loadConfig(projectDir); + const result = {} as Pick; + for (const key of keys) { + result[key] = config[key]; + } + return result; +} diff --git a/src/infra/task/clone.ts b/src/infra/task/clone.ts index e1dd846..a8cd649 100644 --- a/src/infra/task/clone.ts +++ b/src/infra/task/clone.ts @@ -11,7 +11,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { execFileSync } from 'node:child_process'; import { createLogger, slugify } from '../../shared/utils/index.js'; -import { loadConfig } from '../config/index.js'; +import { resolveConfigValue } from '../config/index.js'; import type { WorktreeOptions, WorktreeResult } from './types.js'; export type { WorktreeOptions, WorktreeResult }; @@ -36,11 +36,11 @@ export class CloneManager { * Returns the configured worktree_dir (resolved to absolute), or ../ */ private static resolveCloneBaseDir(projectDir: string): string { - const { global: globalConfig } = loadConfig(projectDir); - if (globalConfig.worktreeDir) { - return path.isAbsolute(globalConfig.worktreeDir) - ? globalConfig.worktreeDir - : path.resolve(projectDir, globalConfig.worktreeDir); + const worktreeDir = resolveConfigValue(projectDir, 'worktreeDir'); + if (worktreeDir) { + return path.isAbsolute(worktreeDir) + ? worktreeDir + : path.resolve(projectDir, worktreeDir); } return path.join(projectDir, '..', 'takt-worktree'); } diff --git a/src/infra/task/summarize.ts b/src/infra/task/summarize.ts index 3da5547..4a6913d 100644 --- a/src/infra/task/summarize.ts +++ b/src/infra/task/summarize.ts @@ -5,7 +5,7 @@ */ import * as wanakana from 'wanakana'; -import { loadConfig } from '../config/index.js'; +import { resolveConfigValues } from '../config/index.js'; import { getProvider, type ProviderType } from '../providers/index.js'; import { createLogger } from '../../shared/utils/index.js'; import { loadTemplate } from '../../shared/prompts/index.js'; @@ -53,7 +53,7 @@ export class TaskSummarizer { taskName: string, options: SummarizeOptions, ): Promise { - const { global: globalConfig } = loadConfig(options.cwd); + const globalConfig = resolveConfigValues(options.cwd, ['branchNameStrategy', 'provider', 'model']); const useLLM = options.useLLM ?? (globalConfig.branchNameStrategy === 'ai'); log.info('Summarizing task name', { taskName, useLLM });