refactor: 設定参照をresolveConfigValueへ統一

This commit is contained in:
nrslib 2026-02-19 10:55:03 +09:00
parent 5dc79946f2
commit cbde7ac654
52 changed files with 417 additions and 276 deletions

View File

@ -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();

View File

@ -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();

View File

@ -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');
}

View File

@ -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 },
},
});

View File

@ -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');

View File

@ -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(),

View File

@ -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);

View File

@ -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';

View File

@ -118,7 +118,11 @@ vi.mock('../infra/config/global/globalConfig.js', async (importOriginal) => {
const original = await importOriginal<typeof import('../infra/config/global/globalConfig.js')>();
return {
...original,
loadGlobalConfig: vi.fn().mockReturnValue({}),
loadGlobalConfig: vi.fn().mockReturnValue({
language: 'en',
enableBuiltinPieces: true,
disabledBuiltins: [],
}),
getLanguage: vi.fn().mockReturnValue('en'),
getDisabledBuiltins: vi.fn().mockReturnValue([]),
};

View File

@ -100,7 +100,11 @@ vi.mock('../infra/config/global/globalConfig.js', async (importOriginal) => {
const original = await importOriginal<typeof import('../infra/config/global/globalConfig.js')>();
return {
...original,
loadGlobalConfig: vi.fn().mockReturnValue({}),
loadGlobalConfig: vi.fn().mockReturnValue({
language: 'en',
enableBuiltinPieces: true,
disabledBuiltins: [],
}),
getLanguage: vi.fn().mockReturnValue('en'),
};
});

View File

@ -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(),

View File

@ -16,8 +16,8 @@ function createMovement(overrides: Partial<PieceMovement> = {}): PieceMovement {
function createBuilder(step: PieceMovement, engineOverrides: Partial<PieceEngineOptions> = {}): 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 },
},
});

View File

@ -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

View File

@ -22,12 +22,21 @@ vi.mock('../infra/config/global/globalConfig.js', async (importOriginal) => {
const original = await importOriginal() as Record<string, unknown>;
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<string, unknown>;
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: [] },
]);

View File

@ -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(),

View File

@ -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');
});

View File

@ -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 },
});
});
});

View File

@ -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',

View File

@ -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' });

View File

@ -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,
});

View File

@ -104,7 +104,7 @@ reset
.command('categories')
.description('Reset piece categories to builtin defaults')
.action(async () => {
await resetCategoriesToDefault();
await resetCategoriesToDefault(resolvedCwd);
});
program

View File

@ -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<void> {
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);

View File

@ -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<void> {
}
// 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);

View File

@ -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(),

View File

@ -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<string, ProviderType>;
/** 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) */

View File

@ -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' });
}

View File

@ -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<void> {
export async function resetCategoriesToDefault(cwd: string): Promise<void> {
header('Reset Categories');
resetPieceCategories();
resetPieceCategories(cwd);
const userPath = getPieceCategoriesPath();
const userPath = getPieceCategoriesPath(cwd);
success('User category overlay reset.');
info(` ${userPath}`);
}

View File

@ -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.');

View File

@ -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<InstructModeResult> {
const { global: globalConfig } = loadConfig(cwd);
const globalConfig = resolveConfigValues(cwd, ['language', 'provider']);
const lang = resolveLanguage(globalConfig.language);
if (!globalConfig.provider) {

View File

@ -521,7 +521,7 @@ export async function selectPiece(
options?: SelectPieceOptions,
): Promise<string | null> {
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);
}

View File

@ -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<number> {
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;

View File

@ -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}`);

View File

@ -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,

View File

@ -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);

View File

@ -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 {

View File

@ -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<AgentResponse>,
provider?: string
): Promise<AgentResponse> {
const resolvedProvider = provider ?? loadConfig(cwd).global.provider ?? 'claude';
const resolvedProvider = provider ?? resolveConfigValue(cwd, 'provider') ?? 'claude';
const sessions = loadPersonaSessions(cwd, resolvedProvider);
const sessionId = sessions[personaName];

View File

@ -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<Piece
movements: pieceConfig.movements.map((s: { name: string }) => 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<void> {
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

View File

@ -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<string, ProviderType>;
/** 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 */

View File

@ -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<InstructModeResult> {
const { global: globalConfig } = loadConfig(cwd);
const globalConfig = resolveConfigValues(cwd, ['language', 'provider']);
const lang = resolveLanguage(globalConfig.language);
if (!globalConfig.provider) {

View File

@ -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,

View File

@ -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,

View File

@ -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 });

View File

@ -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';

View File

@ -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;
}

View File

@ -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}`);
}

View File

@ -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<string, PieceWithSource>,
config: CategoryConfig,
cwd: string,
): CategorizedPieces {
const globalConfig = loadConfig(cwd);
const ignoreMissing = new Set<string>();
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);
}
}

View File

@ -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,
};

View File

@ -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<string>();
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' });

View File

@ -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);
}

View File

@ -0,0 +1,22 @@
import { loadConfig, type LoadedConfig } from './loadConfig.js';
export type ConfigParameterKey = keyof LoadedConfig;
export function resolveConfigValue<K extends ConfigParameterKey>(
projectDir: string,
key: K,
): LoadedConfig[K] {
return loadConfig(projectDir)[key];
}
export function resolveConfigValues<K extends ConfigParameterKey>(
projectDir: string,
keys: readonly K[],
): Pick<LoadedConfig, K> {
const config = loadConfig(projectDir);
const result = {} as Pick<LoadedConfig, K>;
for (const key of keys) {
result[key] = config[key];
}
return result;
}

View File

@ -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');
}

View File

@ -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<string> {
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 });