add-model-to-persona-providers (#324)

* takt: add-model-to-persona-providers

* refactor: loadConfigを廃止しresolveConfigValueにキー単位解決を一元化

loadConfig()による一括マージを廃止し、resolveConfigValue()でキーごとに
global/project/piece/envの優先順位を宣言的に解決する方式に移行。
providerOptionsの優先順位をglobal < piece < project < envに修正し、
sourceトラッキングでOptionsBuilderのマージ方向を制御する。
This commit is contained in:
nrs 2026-02-20 11:12:46 +09:00 committed by GitHub
parent 22901cd8cb
commit dec77e069e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
39 changed files with 761 additions and 445 deletions

View File

@ -55,12 +55,17 @@ concurrency: 2 # Concurrent task execution for takt run (1-10)
# ===================================== # =====================================
# Piece-related settings (global defaults) # Piece-related settings (global defaults)
# ===================================== # =====================================
# 1) Route provider per persona # 1) Route provider/model per persona
# persona_providers: # persona_providers:
# coder: codex # Run coder persona on codex # coder:
# reviewer: claude # Run reviewer persona on claude # provider: codex # Run coder persona on Codex
# model: o3-mini # Use o3-mini model (optional)
# reviewer:
# provider: claude # Run reviewer persona on Claude
# 2) Provider options (global < project < piece) # 2) Provider options
# Priority (for piece-capable keys such as provider/model/provider_options):
# global < piece < project < env
# provider_options: # provider_options:
# codex: # codex:
# network_access: true # Allow network access for Codex # network_access: true # Allow network access for Codex

View File

@ -55,12 +55,17 @@ concurrency: 2 # takt run の同時実行数1-10
# ===================================== # =====================================
# ピースにも関わる設定global defaults # ピースにも関わる設定global defaults
# ===================================== # =====================================
# 1) ペルソナ単位でプロバイダーを切り替える # 1) ペルソナ単位でプロバイダー・モデルを切り替える
# persona_providers: # persona_providers:
# coder: codex # coderペルソナはcodexで実行 # coder:
# reviewer: claude # reviewerペルソナはclaudeで実行 # provider: codex # coderペルソナはcodexで実行
# model: o3-mini # 使用モデル(省略可)
# reviewer:
# provider: claude # reviewerペルソナはclaudeで実行
# 2) provider 固有オプションglobal < project < piece # 2) provider 固有オプション
# 優先順位provider/model/provider_options 等の piece 対応キー):
# global < piece < project < env
# provider_options: # provider_options:
# codex: # codex:
# network_access: true # Codex実行時のネットワークアクセス許可 # network_access: true # Codex実行時のネットワークアクセス許可

View File

@ -34,11 +34,14 @@ interactive_preview_movements: 3 # インタラクティブモードでの move
# - gradle # .runtime/ に Gradle キャッシュ/設定を準備 # - gradle # .runtime/ に Gradle キャッシュ/設定を準備
# - node # .runtime/ に npm キャッシュを準備 # - node # .runtime/ に npm キャッシュを準備
# persona ごとの provider 上書き(省略可) # persona ごとの provider / model 上書き(省略可)
# piece を複製せずに特定の persona を別の provider にルーティング # piece を複製せずに特定の persona を別の provider / model にルーティング
# persona_providers: # persona_providers:
# coder: codex # coder を Codex で実行 # coder:
# ai-antipattern-reviewer: claude # レビュアーは Claude のまま # provider: codex # coder を Codex で実行
# model: o3-mini # 使用モデル(省略可)
# ai-antipattern-reviewer:
# provider: claude # レビュアーは Claude のまま
# provider 固有のパーミッションプロファイル(省略可) # provider 固有のパーミッションプロファイル(省略可)
# 優先順位: プロジェクト上書き > グローバル上書き > プロジェクトデフォルト > グローバルデフォルト > required_permission_mode下限 # 優先順位: プロジェクト上書き > グローバル上書き > プロジェクトデフォルト > グローバルデフォルト > required_permission_mode下限
@ -97,7 +100,7 @@ interactive_preview_movements: 3 # インタラクティブモードでの move
| `verbose` | boolean | - | 詳細出力モード | | `verbose` | boolean | - | 詳細出力モード |
| `minimal_output` | boolean | `false` | AI 出力を抑制CI 向け) | | `minimal_output` | boolean | `false` | AI 出力を抑制CI 向け) |
| `runtime` | object | - | ランタイム環境デフォルト(例: `prepare: [gradle, node]` | | `runtime` | object | - | ランタイム環境デフォルト(例: `prepare: [gradle, node]` |
| `persona_providers` | object | - | persona ごとの provider 上書き(例: `coder: codex` | | `persona_providers` | object | - | persona ごとの provider / model 上書き(例: `coder: { provider: codex, model: o3-mini }` |
| `provider_options` | object | - | グローバルな provider 固有オプション | | `provider_options` | object | - | グローバルな provider 固有オプション |
| `provider_profiles` | object | - | provider 固有のパーミッションプロファイル | | `provider_profiles` | object | - | provider 固有のパーミッションプロファイル |
| `anthropic_api_key` | string | - | Claude 用 Anthropic API キー | | `anthropic_api_key` | string | - | Claude 用 Anthropic API キー |
@ -286,16 +289,21 @@ movement の `required_permission_mode` は最低限の下限を設定します
### Persona Provider ### Persona Provider
piece を複製せずに、特定の persona を別の provider にルーティングできます。 piece を複製せずに、特定の persona を別の provider や model にルーティングできます。
```yaml ```yaml
# ~/.takt/config.yaml # ~/.takt/config.yaml
persona_providers: persona_providers:
coder: codex # coder persona を Codex で実行 coder:
ai-antipattern-reviewer: claude # レビュアーは Claude のまま provider: codex # coder persona を Codex で実行
model: o3-mini # 使用モデル(省略可)
ai-antipattern-reviewer:
provider: claude # レビュアーは Claude のまま
``` ```
これにより、単一の piece 内で provider を混在させることができます。persona 名は movement 定義の `persona` キーに対してマッチされます。 `provider``model` はいずれも省略可能です。`model` の解決優先度: movement YAML の `model` > `persona_providers[persona].model` > グローバル `model`
これにより、単一の piece 内で provider や model を混在させることができます。persona 名は movement 定義の `persona` キーに対してマッチされます。
## Piece カテゴリ ## Piece カテゴリ

View File

@ -34,11 +34,14 @@ interactive_preview_movements: 3 # Movement previews in interactive mode (0-10,
# - gradle # Prepare Gradle cache/config in .runtime/ # - gradle # Prepare Gradle cache/config in .runtime/
# - node # Prepare npm cache in .runtime/ # - node # Prepare npm cache in .runtime/
# Per-persona provider overrides (optional) # Per-persona provider/model overrides (optional)
# Route specific personas to different providers without duplicating pieces # Route specific personas to different providers and models without duplicating pieces
# persona_providers: # persona_providers:
# coder: codex # Run coder on Codex # coder:
# ai-antipattern-reviewer: claude # Keep reviewers on Claude # provider: codex # Run coder on Codex
# model: o3-mini # Use o3-mini model (optional)
# ai-antipattern-reviewer:
# provider: claude # Keep reviewers on Claude
# Provider-specific permission profiles (optional) # Provider-specific permission profiles (optional)
# Priority: project override > global override > project default > global default > required_permission_mode (floor) # Priority: project override > global override > project default > global default > required_permission_mode (floor)
@ -97,7 +100,7 @@ interactive_preview_movements: 3 # Movement previews in interactive mode (0-10,
| `verbose` | boolean | - | Verbose output mode | | `verbose` | boolean | - | Verbose output mode |
| `minimal_output` | boolean | `false` | Suppress AI output (for CI) | | `minimal_output` | boolean | `false` | Suppress AI output (for CI) |
| `runtime` | object | - | Runtime environment defaults (e.g., `prepare: [gradle, node]`) | | `runtime` | object | - | Runtime environment defaults (e.g., `prepare: [gradle, node]`) |
| `persona_providers` | object | - | Per-persona provider overrides (e.g., `coder: codex`) | | `persona_providers` | object | - | Per-persona provider/model overrides (e.g., `coder: { provider: codex, model: o3-mini }`) |
| `provider_options` | object | - | Global provider-specific options | | `provider_options` | object | - | Global provider-specific options |
| `provider_profiles` | object | - | Provider-specific permission profiles | | `provider_profiles` | object | - | Provider-specific permission profiles |
| `anthropic_api_key` | string | - | Anthropic API key for Claude | | `anthropic_api_key` | string | - | Anthropic API key for Claude |
@ -286,16 +289,21 @@ The `required_permission_mode` on a movement sets the minimum floor. If the reso
### Persona Providers ### Persona Providers
Route specific personas to different providers without duplicating pieces: Route specific personas to different providers and models without duplicating pieces:
```yaml ```yaml
# ~/.takt/config.yaml # ~/.takt/config.yaml
persona_providers: persona_providers:
coder: codex # Run coder persona on Codex coder:
ai-antipattern-reviewer: claude # Keep reviewers on Claude provider: codex # Run coder persona on Codex
model: o3-mini # Use o3-mini model (optional)
ai-antipattern-reviewer:
provider: claude # Keep reviewers on Claude
``` ```
This allows mixing providers within a single piece. The persona name is matched against the `persona` key in the movement definition. Both `provider` and `model` are optional. `model` resolution priority: movement YAML `model` > `persona_providers[persona].model` > global `model`.
This allows mixing providers and models within a single piece. The persona name is matched against the `persona` key in the movement definition.
## Piece Categories ## Piece Categories

View File

@ -34,6 +34,11 @@ vi.mock('../features/tasks/execute/selectAndExecute.js', () => ({
determinePiece: vi.fn(), determinePiece: vi.fn(),
})); }));
vi.mock('../infra/task/index.js', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
summarizeTaskName: vi.fn().mockResolvedValue('test-task'),
}));
vi.mock('../infra/github/issue.js', () => ({ vi.mock('../infra/github/issue.js', () => ({
isIssueReference: vi.fn((s: string) => /^#\d+$/.test(s)), isIssueReference: vi.fn((s: string) => /^#\d+$/.test(s)),
resolveIssueTask: vi.fn(), resolveIssueTask: vi.fn(),

View File

@ -20,16 +20,6 @@ vi.mock('../infra/config/global/globalConfig.js', () => ({
loadGlobalConfig: () => ({}), loadGlobalConfig: () => ({}),
})); }));
vi.mock('../infra/config/loadConfig.js', () => ({
loadConfig: () => ({
global: {
language: 'en',
enableBuiltinPieces: true,
},
project: {},
}),
}));
const mockLogError = vi.fn(); const mockLogError = vi.fn();
const mockInfo = vi.fn(); const mockInfo = vi.fn();
vi.mock('../shared/ui/index.js', () => ({ vi.mock('../shared/ui/index.js', () => ({

View File

@ -35,9 +35,9 @@ import {
getLanguage, getLanguage,
loadProjectConfig, loadProjectConfig,
isVerboseMode, isVerboseMode,
resolveConfigValue,
invalidateGlobalConfigCache, invalidateGlobalConfigCache,
} from '../infra/config/index.js'; } from '../infra/config/index.js';
import { loadConfig } from '../infra/config/loadConfig.js';
describe('getBuiltinPiece', () => { describe('getBuiltinPiece', () => {
it('should return builtin piece when it exists in resources', () => { it('should return builtin piece when it exists in resources', () => {
@ -472,7 +472,7 @@ describe('analytics config resolution', () => {
}); });
}); });
it('should merge analytics as project > global in loadConfig', () => { it('should merge analytics as project > global in resolveConfigValue', () => {
const globalConfigDir = process.env.TAKT_CONFIG_DIR!; const globalConfigDir = process.env.TAKT_CONFIG_DIR!;
mkdirSync(globalConfigDir, { recursive: true }); mkdirSync(globalConfigDir, { recursive: true });
writeFileSync(join(globalConfigDir, 'config.yaml'), [ writeFileSync(join(globalConfigDir, 'config.yaml'), [
@ -492,8 +492,8 @@ describe('analytics config resolution', () => {
' retention_days: 14', ' retention_days: 14',
].join('\n')); ].join('\n'));
const config = loadConfig(testDir); const analytics = resolveConfigValue(testDir, 'analytics');
expect(config.analytics).toEqual({ expect(analytics).toEqual({
enabled: true, enabled: true,
eventsPath: '/tmp/project-analytics', eventsPath: '/tmp/project-analytics',
retentionDays: 14, retentionDays: 14,

View File

@ -1,10 +1,10 @@
/** /**
* Tests for persona_providers config-level provider override. * Tests for persona_providers config-level provider/model override.
* *
* Verifies movement-level provider resolution for stepProvider: * Verifies movement-level provider/model resolution for stepProvider/stepModel:
* 1. Movement YAML provider (highest) * 1. Movement YAML provider (highest)
* 2. persona_providers[personaDisplayName] * 2. persona_providers[personaDisplayName].provider / .model
* 3. CLI provider (lowest) * 3. CLI provider / model (lowest)
*/ */
import { describe, it, expect, beforeEach, vi } from 'vitest'; import { describe, it, expect, beforeEach, vi } from 'vitest';
@ -46,7 +46,7 @@ describe('PieceEngine persona_providers override', () => {
applyDefaultMocks(); applyDefaultMocks();
}); });
it('should use persona_providers when movement has no provider and persona matches', async () => { it('should use persona_providers.provider when movement has no provider and persona matches', async () => {
const movement = makeMovement('implement', { const movement = makeMovement('implement', {
personaDisplayName: 'coder', personaDisplayName: 'coder',
rules: [makeRule('done', 'COMPLETE')], rules: [makeRule('done', 'COMPLETE')],
@ -66,7 +66,7 @@ describe('PieceEngine persona_providers override', () => {
const engine = new PieceEngine(config, '/tmp/project', 'test task', { const engine = new PieceEngine(config, '/tmp/project', 'test task', {
projectCwd: '/tmp/project', projectCwd: '/tmp/project',
provider: 'claude', provider: 'claude',
personaProviders: { coder: 'codex' }, personaProviders: { coder: { provider: 'codex' } },
}); });
await engine.run(); await engine.run();
@ -96,7 +96,7 @@ describe('PieceEngine persona_providers override', () => {
const engine = new PieceEngine(config, '/tmp/project', 'test task', { const engine = new PieceEngine(config, '/tmp/project', 'test task', {
projectCwd: '/tmp/project', projectCwd: '/tmp/project',
provider: 'claude', provider: 'claude',
personaProviders: { coder: 'codex' }, personaProviders: { coder: { provider: 'codex' } },
}); });
await engine.run(); await engine.run();
@ -127,7 +127,7 @@ describe('PieceEngine persona_providers override', () => {
const engine = new PieceEngine(config, '/tmp/project', 'test task', { const engine = new PieceEngine(config, '/tmp/project', 'test task', {
projectCwd: '/tmp/project', projectCwd: '/tmp/project',
provider: 'mock', provider: 'mock',
personaProviders: { coder: 'codex' }, personaProviders: { coder: { provider: 'codex' } },
}); });
await engine.run(); await engine.run();
@ -194,7 +194,7 @@ describe('PieceEngine persona_providers override', () => {
const engine = new PieceEngine(config, '/tmp/project', 'test task', { const engine = new PieceEngine(config, '/tmp/project', 'test task', {
projectCwd: '/tmp/project', projectCwd: '/tmp/project',
provider: 'claude', provider: 'claude',
personaProviders: { coder: 'codex' }, personaProviders: { coder: { provider: 'codex' } },
}); });
await engine.run(); await engine.run();
@ -207,4 +207,66 @@ describe('PieceEngine persona_providers override', () => {
expect(calls[1][2].provider).toBe('claude'); expect(calls[1][2].provider).toBe('claude');
expect(calls[1][2].stepProvider).toBe('codex'); expect(calls[1][2].stepProvider).toBe('codex');
}); });
it('should use persona_providers.model as stepModel when step.model is undefined', async () => {
const movement = makeMovement('implement', {
personaDisplayName: 'coder',
rules: [makeRule('done', 'COMPLETE')],
});
const config: PieceConfig = {
name: 'persona-model-test',
movements: [movement],
initialMovement: 'implement',
maxMovements: 1,
};
mockRunAgentSequence([
makeResponse({ persona: movement.persona, content: 'done' }),
]);
mockDetectMatchedRuleSequence([{ index: 0, method: 'phase1_tag' }]);
const engine = new PieceEngine(config, '/tmp/project', 'test task', {
projectCwd: '/tmp/project',
provider: 'claude',
model: 'global-model',
personaProviders: { coder: { provider: 'codex', model: 'o3-mini' } },
});
await engine.run();
const options = vi.mocked(runAgent).mock.calls[0][2];
expect(options.stepProvider).toBe('codex');
expect(options.stepModel).toBe('o3-mini');
});
it('should fallback to input.model when persona_providers.model is not set', async () => {
const movement = makeMovement('implement', {
personaDisplayName: 'coder',
rules: [makeRule('done', 'COMPLETE')],
});
const config: PieceConfig = {
name: 'persona-model-fallback',
movements: [movement],
initialMovement: 'implement',
maxMovements: 1,
};
mockRunAgentSequence([
makeResponse({ persona: movement.persona, content: 'done' }),
]);
mockDetectMatchedRuleSequence([{ index: 0, method: 'phase1_tag' }]);
const engine = new PieceEngine(config, '/tmp/project', 'test task', {
projectCwd: '/tmp/project',
provider: 'claude',
model: 'global-model',
personaProviders: { coder: { provider: 'codex' } },
});
await engine.run();
const options = vi.mocked(runAgent).mock.calls[0][2];
expect(options.stepProvider).toBe('codex');
expect(options.stepModel).toBe('global-model');
});
}); });

View File

@ -54,7 +54,7 @@ describe('PieceEngine provider_options resolution', () => {
} }
}); });
it('should merge provider_options in order: global < project < movement', async () => { it('should merge provider_options in order: global < piece/movement < project', async () => {
const movement = makeMovement('implement', { const movement = makeMovement('implement', {
providerOptions: { providerOptions: {
codex: { networkAccess: false }, codex: { networkAccess: false },
@ -78,6 +78,7 @@ describe('PieceEngine provider_options resolution', () => {
engine = new PieceEngine(config, tmpDir, 'test task', { engine = new PieceEngine(config, tmpDir, 'test task', {
projectCwd: tmpDir, projectCwd: tmpDir,
provider: 'claude', provider: 'claude',
providerOptionsSource: 'project',
providerOptions: { providerOptions: {
codex: { networkAccess: true }, codex: { networkAccess: true },
claude: { sandbox: { allowUnsandboxedCommands: false } }, claude: { sandbox: { allowUnsandboxedCommands: false } },
@ -89,7 +90,7 @@ describe('PieceEngine provider_options resolution', () => {
const options = vi.mocked(runAgent).mock.calls[0]?.[2]; const options = vi.mocked(runAgent).mock.calls[0]?.[2];
expect(options?.providerOptions).toEqual({ expect(options?.providerOptions).toEqual({
codex: { networkAccess: false }, codex: { networkAccess: true },
opencode: { networkAccess: true }, opencode: { networkAccess: true },
claude: { claude: {
sandbox: { sandbox: {

View File

@ -7,32 +7,20 @@ import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path'; import { dirname, join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
const loadConfigMock = vi.hoisted(() => vi.fn()); const resolvedState = vi.hoisted(() => ({ value: {} as Record<string, unknown> }));
vi.mock('../infra/config/paths.js', () => ({ vi.mock('../infra/config/paths.js', () => ({
getGlobalConfigDir: () => '/tmp/.takt', getGlobalConfigDir: () => '/tmp/.takt',
})); }));
vi.mock('../infra/config/loadConfig.js', () => ({
loadConfig: loadConfigMock,
}));
vi.mock('../infra/config/resolvePieceConfigValue.js', () => ({ vi.mock('../infra/config/resolvePieceConfigValue.js', () => ({
resolvePieceConfigValue: (_projectDir: string, key: string) => { resolvePieceConfigValue: (_projectDir: string, key: string) => {
const loaded = loadConfigMock() as Record<string, Record<string, unknown>>; return resolvedState.value[key];
const global = loaded?.global ?? {};
const project = loaded?.project ?? {};
const merged: Record<string, unknown> = { ...global, ...project };
return merged[key];
}, },
resolvePieceConfigValues: (_projectDir: string, keys: readonly string[]) => { resolvePieceConfigValues: (_projectDir: string, keys: readonly string[]) => {
const loaded = loadConfigMock() as Record<string, Record<string, unknown>>;
const global = loaded?.global ?? {};
const project = loaded?.project ?? {};
const merged: Record<string, unknown> = { ...global, ...project };
const result: Record<string, unknown> = {}; const result: Record<string, unknown> = {};
for (const key of keys) { for (const key of keys) {
result[key] = merged[key]; result[key] = resolvedState.value[key];
} }
return result; return result;
}, },
@ -49,15 +37,12 @@ function createTempCategoriesPath(): string {
describe('getPieceCategoriesPath', () => { describe('getPieceCategoriesPath', () => {
beforeEach(() => { beforeEach(() => {
loadConfigMock.mockReset(); resolvedState.value = {};
}); });
it('should return configured path when pieceCategoriesFile is set', () => { it('should return configured path when pieceCategoriesFile is set', () => {
// Given // Given
loadConfigMock.mockReturnValue({ resolvedState.value = { pieceCategoriesFile: '/custom/piece-categories.yaml' };
global: { pieceCategoriesFile: '/custom/piece-categories.yaml' },
project: {},
});
// When // When
const path = getPieceCategoriesPath(process.cwd()); const path = getPieceCategoriesPath(process.cwd());
@ -68,7 +53,7 @@ describe('getPieceCategoriesPath', () => {
it('should return default path when pieceCategoriesFile is not set', () => { it('should return default path when pieceCategoriesFile is not set', () => {
// Given // Given
loadConfigMock.mockReturnValue({ global: {}, project: {} }); resolvedState.value = {};
// When // When
const path = getPieceCategoriesPath(process.cwd()); const path = getPieceCategoriesPath(process.cwd());
@ -79,9 +64,11 @@ describe('getPieceCategoriesPath', () => {
it('should rethrow when global config loading fails', () => { it('should rethrow when global config loading fails', () => {
// Given // Given
loadConfigMock.mockImplementation(() => { resolvedState.value = new Proxy({}, {
get() {
throw new Error('invalid global config'); throw new Error('invalid global config');
}); },
}) as Record<string, unknown>;
// When / Then // When / Then
expect(() => getPieceCategoriesPath(process.cwd())).toThrow('invalid global config'); expect(() => getPieceCategoriesPath(process.cwd())).toThrow('invalid global config');
@ -92,7 +79,7 @@ describe('resetPieceCategories', () => {
const tempRoots: string[] = []; const tempRoots: string[] = [];
beforeEach(() => { beforeEach(() => {
loadConfigMock.mockReset(); resolvedState.value = {};
}); });
afterEach(() => { afterEach(() => {
@ -106,10 +93,7 @@ describe('resetPieceCategories', () => {
// Given // Given
const categoriesPath = createTempCategoriesPath(); const categoriesPath = createTempCategoriesPath();
tempRoots.push(dirname(dirname(categoriesPath))); tempRoots.push(dirname(dirname(categoriesPath)));
loadConfigMock.mockReturnValue({ resolvedState.value = { pieceCategoriesFile: categoriesPath };
global: { pieceCategoriesFile: categoriesPath },
project: {},
});
// When // When
resetPieceCategories(process.cwd()); resetPieceCategories(process.cwd());
@ -125,10 +109,7 @@ describe('resetPieceCategories', () => {
const categoriesDir = dirname(categoriesPath); const categoriesDir = dirname(categoriesPath);
const tempRoot = dirname(categoriesDir); const tempRoot = dirname(categoriesDir);
tempRoots.push(tempRoot); tempRoots.push(tempRoot);
loadConfigMock.mockReturnValue({ resolvedState.value = { pieceCategoriesFile: categoriesPath };
global: { pieceCategoriesFile: categoriesPath },
project: {},
});
mkdirSync(categoriesDir, { recursive: true }); mkdirSync(categoriesDir, { recursive: true });
writeFileSync(categoriesPath, 'piece_categories:\n old:\n - stale-piece\n', 'utf-8'); writeFileSync(categoriesPath, 'piece_categories:\n old:\n - stale-piece\n', 'utf-8');

View File

@ -42,7 +42,7 @@ describe('loadGlobalConfig', () => {
expect(config.logLevel).toBe('info'); expect(config.logLevel).toBe('info');
expect(config.provider).toBe('claude'); expect(config.provider).toBe('claude');
expect(config.model).toBeUndefined(); expect(config.model).toBeUndefined();
expect(config.debug).toBeUndefined(); expect(config.verbose).toBeUndefined();
expect(config.pipeline).toBeUndefined(); expect(config.pipeline).toBeUndefined();
}); });
@ -451,8 +451,11 @@ describe('loadGlobalConfig', () => {
[ [
'language: en', 'language: en',
'persona_providers:', 'persona_providers:',
' coder: codex', ' coder:',
' reviewer: claude', ' provider: codex',
' reviewer:',
' provider: claude',
' model: claude-3-5-sonnet-latest',
].join('\n'), ].join('\n'),
'utf-8', 'utf-8',
); );
@ -460,8 +463,29 @@ describe('loadGlobalConfig', () => {
const config = loadGlobalConfig(); const config = loadGlobalConfig();
expect(config.personaProviders).toEqual({ expect(config.personaProviders).toEqual({
coder: 'codex', coder: { provider: 'codex' },
reviewer: 'claude', reviewer: { provider: 'claude', model: 'claude-3-5-sonnet-latest' },
});
});
it('should load persona_providers with model only (no provider)', () => {
const taktDir = join(testHomeDir, '.takt');
mkdirSync(taktDir, { recursive: true });
writeFileSync(
getGlobalConfigPath(),
[
'language: en',
'persona_providers:',
' coder:',
' model: o3-mini',
].join('\n'),
'utf-8',
);
const config = loadGlobalConfig();
expect(config.personaProviders).toEqual({
coder: { model: 'o3-mini' },
}); });
}); });
@ -471,12 +495,28 @@ describe('loadGlobalConfig', () => {
writeFileSync(getGlobalConfigPath(), 'language: en\n', 'utf-8'); writeFileSync(getGlobalConfigPath(), 'language: en\n', 'utf-8');
const config = loadGlobalConfig(); const config = loadGlobalConfig();
config.personaProviders = { coder: 'codex' }; config.personaProviders = { coder: { provider: 'codex', model: 'o3-mini' } };
saveGlobalConfig(config); saveGlobalConfig(config);
invalidateGlobalConfigCache(); invalidateGlobalConfigCache();
const reloaded = loadGlobalConfig(); const reloaded = loadGlobalConfig();
expect(reloaded.personaProviders).toEqual({ coder: 'codex' }); expect(reloaded.personaProviders).toEqual({ coder: { provider: 'codex', model: 'o3-mini' } });
});
it('should normalize legacy string format to object format', () => {
const taktDir = join(testHomeDir, '.takt');
mkdirSync(taktDir, { recursive: true });
writeFileSync(
getGlobalConfigPath(),
'language: en\npersona_providers:\n coder: codex\n',
'utf-8',
);
const config = loadGlobalConfig();
expect(config.personaProviders).toEqual({
coder: { provider: 'codex' },
});
}); });
it('should have undefined personaProviders by default', () => { it('should have undefined personaProviders by default', () => {
@ -497,6 +537,42 @@ describe('loadGlobalConfig', () => {
const reloaded = loadGlobalConfig(); const reloaded = loadGlobalConfig();
expect(reloaded.personaProviders).toBeUndefined(); expect(reloaded.personaProviders).toBeUndefined();
}); });
it('should throw when persona entry has codex provider with Claude model alias', () => {
const taktDir = join(testHomeDir, '.takt');
mkdirSync(taktDir, { recursive: true });
writeFileSync(
getGlobalConfigPath(),
'language: en\npersona_providers:\n coder:\n provider: codex\n model: opus\n',
'utf-8',
);
expect(() => loadGlobalConfig()).toThrow(/Claude model alias/);
});
it('should throw when persona entry has opencode provider without model', () => {
const taktDir = join(testHomeDir, '.takt');
mkdirSync(taktDir, { recursive: true });
writeFileSync(
getGlobalConfigPath(),
'language: en\npersona_providers:\n reviewer:\n provider: opencode\n',
'utf-8',
);
expect(() => loadGlobalConfig()).toThrow(/requires model/);
});
it('should not throw when persona entry has opencode provider with compatible model', () => {
const taktDir = join(testHomeDir, '.takt');
mkdirSync(taktDir, { recursive: true });
writeFileSync(
getGlobalConfigPath(),
'language: en\npersona_providers:\n coder:\n provider: opencode\n model: opencode/big-pickle\n',
'utf-8',
);
expect(() => loadGlobalConfig()).not.toThrow();
});
}); });
describe('runtime', () => { describe('runtime', () => {

View File

@ -19,9 +19,9 @@ describe('Schemas accept opencode provider', () => {
it('should accept opencode in GlobalConfigSchema persona_providers field', () => { it('should accept opencode in GlobalConfigSchema persona_providers field', () => {
const result = GlobalConfigSchema.parse({ const result = GlobalConfigSchema.parse({
persona_providers: { coder: 'opencode' }, persona_providers: { coder: { provider: 'opencode' } },
}); });
expect(result.persona_providers).toEqual({ coder: 'opencode' }); expect(result.persona_providers).toEqual({ coder: { provider: 'opencode' } });
}); });
it('should accept opencode_api_key in GlobalConfigSchema', () => { it('should accept opencode_api_key in GlobalConfigSchema', () => {

View File

@ -68,7 +68,7 @@ describe('OptionsBuilder.buildBaseOptions', () => {
expect(options.permissionMode).toBe('edit'); expect(options.permissionMode).toBe('edit');
}); });
it('merges provider options with precedence: global < project < movement', () => { it('merges provider options with precedence: global < movement < project', () => {
const step = createMovement({ const step = createMovement({
providerOptions: { providerOptions: {
codex: { networkAccess: false }, codex: { networkAccess: false },
@ -76,6 +76,7 @@ describe('OptionsBuilder.buildBaseOptions', () => {
}, },
}); });
const builder = createBuilder(step, { const builder = createBuilder(step, {
providerOptionsSource: 'project',
providerOptions: { providerOptions: {
codex: { networkAccess: true }, codex: { networkAccess: true },
claude: { sandbox: { allowUnsandboxedCommands: true } }, claude: { sandbox: { allowUnsandboxedCommands: true } },
@ -86,7 +87,7 @@ describe('OptionsBuilder.buildBaseOptions', () => {
const options = builder.buildBaseOptions(step); const options = builder.buildBaseOptions(step);
expect(options.providerOptions).toEqual({ expect(options.providerOptions).toEqual({
codex: { networkAccess: false }, codex: { networkAccess: true },
opencode: { networkAccess: true }, opencode: { networkAccess: true },
claude: { claude: {
sandbox: { sandbox: {

View File

@ -248,7 +248,7 @@ describe('executePiece session loading', () => {
projectCwd: '/tmp/project', projectCwd: '/tmp/project',
provider: 'codex', provider: 'codex',
model: 'gpt-5', model: 'gpt-5',
personaProviders: { coder: 'opencode' }, personaProviders: { coder: { provider: 'opencode' } },
}); });
const mockInfo = vi.mocked(info); const mockInfo = vi.mocked(info);

View File

@ -31,10 +31,9 @@ vi.mock('../features/tasks/index.js', () => ({
executeTask: mockExecuteTask, executeTask: mockExecuteTask,
})); }));
// Mock loadGlobalConfig const mockResolveConfigValues = vi.fn();
const mockLoadGlobalConfig = vi.fn(); vi.mock('../infra/config/index.js', () => ({
vi.mock('../infra/config/global/globalConfig.js', async (importOriginal) => ({ ...(await importOriginal<Record<string, unknown>>()), resolveConfigValues: mockResolveConfigValues,
loadGlobalConfig: mockLoadGlobalConfig,
})); }));
// Mock execFileSync for git operations // Mock execFileSync for git operations
@ -73,12 +72,7 @@ describe('executePipeline', () => {
// Default: git operations succeed // Default: git operations succeed
mockExecFileSync.mockReturnValue('abc1234\n'); mockExecFileSync.mockReturnValue('abc1234\n');
// Default: no pipeline config // Default: no pipeline config
mockLoadGlobalConfig.mockReturnValue({ mockResolveConfigValues.mockReturnValue({ pipeline: undefined });
language: 'en',
defaultPiece: 'default',
logLevel: 'info',
provider: 'claude',
});
}); });
it('should return exit code 2 when neither --issue nor --task is specified', async () => { it('should return exit code 2 when neither --issue nor --task is specified', async () => {
@ -311,11 +305,7 @@ describe('executePipeline', () => {
describe('PipelineConfig template expansion', () => { describe('PipelineConfig template expansion', () => {
it('should use commit_message_template when configured', async () => { it('should use commit_message_template when configured', async () => {
mockLoadGlobalConfig.mockReturnValue({ mockResolveConfigValues.mockReturnValue({
language: 'en',
defaultPiece: 'default',
logLevel: 'info',
provider: 'claude',
pipeline: { pipeline: {
commitMessageTemplate: 'fix: {title} (#{issue})', commitMessageTemplate: 'fix: {title} (#{issue})',
}, },
@ -347,11 +337,7 @@ describe('executePipeline', () => {
}); });
it('should use default_branch_prefix when configured', async () => { it('should use default_branch_prefix when configured', async () => {
mockLoadGlobalConfig.mockReturnValue({ mockResolveConfigValues.mockReturnValue({
language: 'en',
defaultPiece: 'default',
logLevel: 'info',
provider: 'claude',
pipeline: { pipeline: {
defaultBranchPrefix: 'feat/', defaultBranchPrefix: 'feat/',
}, },
@ -383,11 +369,7 @@ describe('executePipeline', () => {
}); });
it('should use pr_body_template when configured for PR creation', async () => { it('should use pr_body_template when configured for PR creation', async () => {
mockLoadGlobalConfig.mockReturnValue({ mockResolveConfigValues.mockReturnValue({
language: 'en',
defaultPiece: 'default',
logLevel: 'info',
provider: 'claude',
pipeline: { pipeline: {
prBodyTemplate: '## Summary\n{issue_body}\n\nCloses #{issue}', prBodyTemplate: '## Summary\n{issue_body}\n\nCloses #{issue}',
}, },

View File

@ -7,7 +7,7 @@ describe('resolveMovementProviderModel', () => {
const result = resolveMovementProviderModel({ const result = resolveMovementProviderModel({
step: { provider: 'codex', model: undefined, personaDisplayName: 'coder' }, step: { provider: 'codex', model: undefined, personaDisplayName: 'coder' },
provider: 'claude', provider: 'claude',
personaProviders: { coder: 'opencode' }, personaProviders: { coder: { provider: 'opencode' } },
}); });
// When: provider/model を解決する // When: provider/model を解決する
@ -15,16 +15,16 @@ describe('resolveMovementProviderModel', () => {
expect(result.provider).toBe('codex'); expect(result.provider).toBe('codex');
}); });
it('should use personaProviders when step.provider is undefined', () => { it('should use personaProviders.provider when step.provider is undefined', () => {
// Given: step.provider が未定義で personaProviders に対応がある // Given: step.provider が未定義で personaProviders に対応がある
const result = resolveMovementProviderModel({ const result = resolveMovementProviderModel({
step: { provider: undefined, model: undefined, personaDisplayName: 'reviewer' }, step: { provider: undefined, model: undefined, personaDisplayName: 'reviewer' },
provider: 'claude', provider: 'claude',
personaProviders: { reviewer: 'opencode' }, personaProviders: { reviewer: { provider: 'opencode' } },
}); });
// When: provider/model を解決する // When: provider/model を解決する
// Then: personaProviders のが使われる // Then: personaProviders の provider が使われる
expect(result.provider).toBe('opencode'); expect(result.provider).toBe('opencode');
}); });
@ -33,7 +33,7 @@ describe('resolveMovementProviderModel', () => {
const result = resolveMovementProviderModel({ const result = resolveMovementProviderModel({
step: { provider: undefined, model: undefined, personaDisplayName: 'unknown' }, step: { provider: undefined, model: undefined, personaDisplayName: 'unknown' },
provider: 'mock', provider: 'mock',
personaProviders: { reviewer: 'codex' }, personaProviders: { reviewer: { provider: 'codex' } },
}); });
// When: provider/model を解決する // When: provider/model を解決する
@ -54,11 +54,12 @@ describe('resolveMovementProviderModel', () => {
expect(result.provider).toBeUndefined(); expect(result.provider).toBeUndefined();
}); });
it('should prefer step.model over input.model', () => { it('should prefer step.model over personaProviders.model and input.model', () => {
// Given: step.model と input.model が両方指定されている // Given: step.model と personaProviders.model と input.model が指定されている
const result = resolveMovementProviderModel({ const result = resolveMovementProviderModel({
step: { provider: undefined, model: 'step-model', personaDisplayName: 'coder' }, step: { provider: undefined, model: 'step-model', personaDisplayName: 'coder' },
model: 'input-model', model: 'input-model',
personaProviders: { coder: { provider: 'codex', model: 'persona-model' } },
}); });
// When: provider/model を解決する // When: provider/model を解決する
@ -66,15 +67,54 @@ describe('resolveMovementProviderModel', () => {
expect(result.model).toBe('step-model'); expect(result.model).toBe('step-model');
}); });
it('should fallback to input.model when step.model is undefined', () => { it('should use personaProviders.model when step.model is undefined', () => {
// Given: step.model が未定義で input.model が指定されている // Given: step.model が未定義で personaProviders.model が指定されている
const result = resolveMovementProviderModel({ const result = resolveMovementProviderModel({
step: { provider: undefined, model: undefined, personaDisplayName: 'coder' }, step: { provider: undefined, model: undefined, personaDisplayName: 'coder' },
model: 'input-model', model: 'input-model',
personaProviders: { coder: { provider: 'codex', model: 'persona-model' } },
});
// When: provider/model を解決する
// Then: personaProviders.model が使われる
expect(result.model).toBe('persona-model');
});
it('should fallback to input.model when step.model and personaProviders.model are undefined', () => {
// Given: step.model と personaProviders.model が未定義で input.model が指定されている
const result = resolveMovementProviderModel({
step: { provider: undefined, model: undefined, personaDisplayName: 'coder' },
model: 'input-model',
personaProviders: { coder: { provider: 'codex' } },
}); });
// When: provider/model を解決する // When: provider/model を解決する
// Then: input.model が使われる // Then: input.model が使われる
expect(result.model).toBe('input-model'); expect(result.model).toBe('input-model');
}); });
it('should return undefined model when all model candidates are missing', () => {
// Given: model の候補がすべて未定義
const result = resolveMovementProviderModel({
step: { provider: undefined, model: undefined, personaDisplayName: 'coder' },
model: undefined,
personaProviders: { coder: { provider: 'codex' } },
});
// Then: model は undefined になる
expect(result.model).toBeUndefined();
});
it('should resolve provider from personaProviders entry with only model specified', () => {
// Given: personaProviders エントリに provider が指定されていないmodel のみ)
const result = resolveMovementProviderModel({
step: { provider: undefined, model: undefined, personaDisplayName: 'coder' },
provider: 'claude',
personaProviders: { coder: { model: 'o3-mini' } },
});
// Then: provider は input.provider、model は personaProviders.model になる
expect(result.provider).toBe('claude');
expect(result.model).toBe('o3-mini');
});
}); });

View File

@ -40,6 +40,13 @@ vi.mock('../infra/config/index.js', () => ({
} }
return result; return result;
}, },
resolveConfigValueWithSource: (_projectDir: string, key: string) => {
const raw = mockLoadConfigRaw() as Record<string, unknown>;
const config = ('global' in raw && 'project' in raw)
? { ...raw.global as Record<string, unknown>, ...raw.project as Record<string, unknown> }
: { ...raw, piece: 'default', provider: 'claude', verbose: false };
return { value: config[key], source: 'project' };
},
})); }));
const mockLoadConfig = mockLoadConfigRaw; const mockLoadConfig = mockLoadConfigRaw;

View File

@ -8,9 +8,8 @@ vi.mock('../infra/providers/index.js', () => ({
getProvider: vi.fn(), getProvider: vi.fn(),
})); }));
vi.mock('../infra/config/global/globalConfig.js', () => ({ vi.mock('../infra/config/index.js', () => ({
loadGlobalConfig: vi.fn(), resolveConfigValues: vi.fn(),
getBuiltinPiecesEnabled: vi.fn().mockReturnValue(true),
})); }));
vi.mock('../shared/utils/index.js', async (importOriginal) => ({ vi.mock('../shared/utils/index.js', async (importOriginal) => ({
@ -23,11 +22,11 @@ vi.mock('../shared/utils/index.js', async (importOriginal) => ({
})); }));
import { getProvider } from '../infra/providers/index.js'; import { getProvider } from '../infra/providers/index.js';
import { loadGlobalConfig } from '../infra/config/global/globalConfig.js'; import { resolveConfigValues } from '../infra/config/index.js';
import { summarizeTaskName } from '../infra/task/summarize.js'; import { summarizeTaskName } from '../infra/task/summarize.js';
const mockGetProvider = vi.mocked(getProvider); const mockGetProvider = vi.mocked(getProvider);
const mockLoadGlobalConfig = vi.mocked(loadGlobalConfig); const mockResolveConfigValues = vi.mocked(resolveConfigValues);
const mockProviderCall = vi.fn(); const mockProviderCall = vi.fn();
const mockProvider = { const mockProvider = {
@ -37,10 +36,7 @@ const mockProvider = {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
mockGetProvider.mockReturnValue(mockProvider); mockGetProvider.mockReturnValue(mockProvider);
mockLoadGlobalConfig.mockReturnValue({ mockResolveConfigValues.mockReturnValue({
language: 'ja',
defaultPiece: 'default',
logLevel: 'info',
provider: 'claude', provider: 'claude',
model: undefined, model: undefined,
branchNameStrategy: 'ai', branchNameStrategy: 'ai',
@ -166,10 +162,7 @@ describe('summarizeTaskName', () => {
it('should use provider from config.yaml', async () => { it('should use provider from config.yaml', async () => {
// Given: config has codex provider with branchNameStrategy: 'ai' // Given: config has codex provider with branchNameStrategy: 'ai'
mockLoadGlobalConfig.mockReturnValue({ mockResolveConfigValues.mockReturnValue({
language: 'ja',
defaultPiece: 'default',
logLevel: 'info',
provider: 'codex', provider: 'codex',
model: 'gpt-4', model: 'gpt-4',
branchNameStrategy: 'ai', branchNameStrategy: 'ai',
@ -228,7 +221,7 @@ describe('summarizeTaskName', () => {
it('should throw error when config load fails', async () => { it('should throw error when config load fails', async () => {
// Given: config loading throws error // Given: config loading throws error
mockLoadGlobalConfig.mockImplementation(() => { mockResolveConfigValues.mockImplementation(() => {
throw new Error('Config not found'); throw new Error('Config not found');
}); });
@ -257,10 +250,7 @@ describe('summarizeTaskName', () => {
it('should use romaji by default', async () => { it('should use romaji by default', async () => {
// Given: branchNameStrategy is not set (undefined) // Given: branchNameStrategy is not set (undefined)
mockLoadGlobalConfig.mockReturnValue({ mockResolveConfigValues.mockReturnValue({
language: 'ja',
defaultPiece: 'default',
logLevel: 'info',
provider: 'claude', provider: 'claude',
model: undefined, model: undefined,
branchNameStrategy: undefined, branchNameStrategy: undefined,
@ -276,10 +266,7 @@ describe('summarizeTaskName', () => {
it('should use AI when branchNameStrategy is ai', async () => { it('should use AI when branchNameStrategy is ai', async () => {
// Given: branchNameStrategy is 'ai' // Given: branchNameStrategy is 'ai'
mockLoadGlobalConfig.mockReturnValue({ mockResolveConfigValues.mockReturnValue({
language: 'ja',
defaultPiece: 'default',
logLevel: 'info',
provider: 'claude', provider: 'claude',
model: undefined, model: undefined,
branchNameStrategy: 'ai', branchNameStrategy: 'ai',
@ -301,10 +288,7 @@ describe('summarizeTaskName', () => {
it('should use romaji when branchNameStrategy is romaji', async () => { it('should use romaji when branchNameStrategy is romaji', async () => {
// Given: branchNameStrategy is 'romaji' // Given: branchNameStrategy is 'romaji'
mockLoadGlobalConfig.mockReturnValue({ mockResolveConfigValues.mockReturnValue({
language: 'ja',
defaultPiece: 'default',
logLevel: 'info',
provider: 'claude', provider: 'claude',
model: undefined, model: undefined,
branchNameStrategy: 'romaji', branchNameStrategy: 'romaji',
@ -320,10 +304,7 @@ describe('summarizeTaskName', () => {
it('should respect explicit useLLM option over config', async () => { it('should respect explicit useLLM option over config', async () => {
// Given: branchNameStrategy is 'romaji' but useLLM is explicitly true // Given: branchNameStrategy is 'romaji' but useLLM is explicitly true
mockLoadGlobalConfig.mockReturnValue({ mockResolveConfigValues.mockReturnValue({
language: 'ja',
defaultPiece: 'default',
logLevel: 'info',
provider: 'claude', provider: 'claude',
model: undefined, model: undefined,
branchNameStrategy: 'romaji', branchNameStrategy: 'romaji',
@ -345,10 +326,7 @@ describe('summarizeTaskName', () => {
it('should respect explicit useLLM false over config with ai strategy', async () => { it('should respect explicit useLLM false over config with ai strategy', async () => {
// Given: branchNameStrategy is 'ai' but useLLM is explicitly false // Given: branchNameStrategy is 'ai' but useLLM is explicitly false
mockLoadGlobalConfig.mockReturnValue({ mockResolveConfigValues.mockReturnValue({
language: 'ja',
defaultPiece: 'default',
logLevel: 'info',
provider: 'claude', provider: 'claude',
model: undefined, model: undefined,
branchNameStrategy: 'ai', branchNameStrategy: 'ai',

View File

@ -5,12 +5,13 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { TaskInfo } from '../infra/task/index.js'; import type { TaskInfo } from '../infra/task/index.js';
const { mockResolveTaskExecution, mockExecutePiece, mockLoadPieceByIdentifier, mockResolvePieceConfigValues, mockBuildTaskResult, mockPersistTaskResult, mockPersistTaskError, mockPostExecutionFlow } = const { mockResolveTaskExecution, mockExecutePiece, mockLoadPieceByIdentifier, mockResolvePieceConfigValues, mockResolveConfigValueWithSource, mockBuildTaskResult, mockPersistTaskResult, mockPersistTaskError, mockPostExecutionFlow } =
vi.hoisted(() => ({ vi.hoisted(() => ({
mockResolveTaskExecution: vi.fn(), mockResolveTaskExecution: vi.fn(),
mockExecutePiece: vi.fn(), mockExecutePiece: vi.fn(),
mockLoadPieceByIdentifier: vi.fn(), mockLoadPieceByIdentifier: vi.fn(),
mockResolvePieceConfigValues: vi.fn(), mockResolvePieceConfigValues: vi.fn(),
mockResolveConfigValueWithSource: vi.fn(),
mockBuildTaskResult: vi.fn(), mockBuildTaskResult: vi.fn(),
mockPersistTaskResult: vi.fn(), mockPersistTaskResult: vi.fn(),
mockPersistTaskError: vi.fn(), mockPersistTaskError: vi.fn(),
@ -40,6 +41,7 @@ vi.mock('../infra/config/index.js', () => ({
loadPieceByIdentifier: (...args: unknown[]) => mockLoadPieceByIdentifier(...args), loadPieceByIdentifier: (...args: unknown[]) => mockLoadPieceByIdentifier(...args),
isPiecePath: () => false, isPiecePath: () => false,
resolvePieceConfigValues: (...args: unknown[]) => mockResolvePieceConfigValues(...args), resolvePieceConfigValues: (...args: unknown[]) => mockResolvePieceConfigValues(...args),
resolveConfigValueWithSource: (...args: unknown[]) => mockResolveConfigValueWithSource(...args),
})); }));
vi.mock('../shared/ui/index.js', () => ({ vi.mock('../shared/ui/index.js', () => ({
@ -90,14 +92,17 @@ describe('executeAndCompleteTask', () => {
model: undefined, model: undefined,
personaProviders: {}, personaProviders: {},
providerProfiles: {}, providerProfiles: {},
providerOptions: {
claude: { sandbox: { allowUnsandboxedCommands: true } },
},
notificationSound: true, notificationSound: true,
notificationSoundEvents: {}, notificationSoundEvents: {},
concurrency: 1, concurrency: 1,
taskPollIntervalMs: 500, taskPollIntervalMs: 500,
}); });
mockResolveConfigValueWithSource.mockReturnValue({
value: {
claude: { sandbox: { allowUnsandboxedCommands: true } },
},
source: 'project',
});
mockBuildTaskResult.mockReturnValue({ success: true }); mockBuildTaskResult.mockReturnValue({ success: true });
mockResolveTaskExecution.mockResolvedValue({ mockResolveTaskExecution.mockResolvedValue({
execCwd: '/project', execCwd: '/project',
@ -136,11 +141,13 @@ describe('executeAndCompleteTask', () => {
taskDisplayLabel?: string; taskDisplayLabel?: string;
taskPrefix?: string; taskPrefix?: string;
providerOptions?: unknown; providerOptions?: unknown;
providerOptionsSource?: string;
}; };
expect(pieceExecutionOptions?.taskDisplayLabel).toBe(taskDisplayLabel); expect(pieceExecutionOptions?.taskDisplayLabel).toBe(taskDisplayLabel);
expect(pieceExecutionOptions?.taskPrefix).toBe(taskDisplayLabel); expect(pieceExecutionOptions?.taskPrefix).toBe(taskDisplayLabel);
expect(pieceExecutionOptions?.providerOptions).toEqual({ expect(pieceExecutionOptions?.providerOptions).toEqual({
claude: { sandbox: { allowUnsandboxedCommands: true } }, claude: { sandbox: { allowUnsandboxedCommands: true } },
}); });
expect(pieceExecutionOptions?.providerOptionsSource).toBe('project');
}); });
}); });

View File

@ -30,7 +30,6 @@ export type {
ObservabilityConfig, ObservabilityConfig,
Language, Language,
PipelineConfig, PipelineConfig,
GlobalConfig,
ProjectConfig, ProjectConfig,
ProviderProfileName, ProviderProfileName,
ProviderPermissionProfile, ProviderPermissionProfile,

View File

@ -5,6 +5,11 @@
import type { MovementProviderOptions, PieceRuntimeConfig } from './piece-types.js'; import type { MovementProviderOptions, PieceRuntimeConfig } from './piece-types.js';
import type { ProviderPermissionProfiles } from './provider-profiles.js'; import type { ProviderPermissionProfiles } from './provider-profiles.js';
export interface PersonaProviderEntry {
provider?: 'claude' | 'codex' | 'opencode' | 'mock';
model?: string;
}
/** Custom agent configuration */ /** Custom agent configuration */
export interface CustomAgentConfig { export interface CustomAgentConfig {
name: string; name: string;
@ -60,8 +65,8 @@ export interface NotificationSoundEventsConfig {
runAbort?: boolean; runAbort?: boolean;
} }
/** Global configuration for takt */ /** Persisted global configuration for ~/.takt/config.yaml */
export interface GlobalConfig { export interface PersistedGlobalConfig {
language: Language; language: Language;
logLevel: 'debug' | 'info' | 'warn' | 'error'; logLevel: 'debug' | 'info' | 'warn' | 'error';
provider?: 'claude' | 'codex' | 'opencode' | 'mock'; provider?: 'claude' | 'codex' | 'opencode' | 'mock';
@ -94,8 +99,8 @@ export interface GlobalConfig {
bookmarksFile?: string; bookmarksFile?: string;
/** Path to piece categories file (default: ~/.takt/preferences/piece-categories.yaml) */ /** Path to piece categories file (default: ~/.takt/preferences/piece-categories.yaml) */
pieceCategoriesFile?: string; pieceCategoriesFile?: string;
/** Per-persona provider overrides (e.g., { coder: 'codex' }) */ /** Per-persona provider and model overrides (e.g., { coder: { provider: 'codex', model: 'o3-mini' } }) */
personaProviders?: Record<string, 'claude' | 'codex' | 'opencode' | 'mock'>; personaProviders?: Record<string, PersonaProviderEntry>;
/** Global provider-specific options (lowest priority) */ /** Global provider-specific options (lowest priority) */
providerOptions?: MovementProviderOptions; providerOptions?: MovementProviderOptions;
/** Provider-specific permission profiles */ /** Provider-specific permission profiles */

View File

@ -359,6 +359,11 @@ export const PieceConfigRawSchema = z.object({
interactive_mode: InteractiveModeSchema.optional(), interactive_mode: InteractiveModeSchema.optional(),
}); });
export const PersonaProviderEntrySchema = z.object({
provider: z.enum(['claude', 'codex', 'opencode', 'mock']).optional(),
model: z.string().optional(),
});
/** Custom agent configuration schema */ /** Custom agent configuration schema */
export const CustomAgentConfigSchema = z.object({ export const CustomAgentConfigSchema = z.object({
name: z.string().min(1), name: z.string().min(1),
@ -443,8 +448,11 @@ export const GlobalConfigSchema = z.object({
bookmarks_file: z.string().optional(), bookmarks_file: z.string().optional(),
/** Path to piece categories file (default: ~/.takt/preferences/piece-categories.yaml) */ /** Path to piece categories file (default: ~/.takt/preferences/piece-categories.yaml) */
piece_categories_file: z.string().optional(), piece_categories_file: z.string().optional(),
/** Per-persona provider overrides (e.g., { coder: 'codex' }) */ /** Per-persona provider and model overrides. */
persona_providers: z.record(z.string(), z.enum(['claude', 'codex', 'opencode', 'mock'])).optional(), persona_providers: z.record(z.string(), z.union([
z.enum(['claude', 'codex', 'opencode', 'mock']),
PersonaProviderEntrySchema,
])).optional(),
/** Global provider-specific options (lowest priority) */ /** Global provider-specific options (lowest priority) */
provider_options: MovementProviderOptionsSchema, provider_options: MovementProviderOptionsSchema,
/** Provider-specific permission profiles */ /** Provider-specific permission profiles */

View File

@ -61,10 +61,10 @@ export type {
// Configuration types (global and project) // Configuration types (global and project)
export type { export type {
PersonaProviderEntry,
CustomAgentConfig, CustomAgentConfig,
ObservabilityConfig, ObservabilityConfig,
Language, Language,
PipelineConfig, PipelineConfig,
GlobalConfig,
ProjectConfig, ProjectConfig,
} from './global-config.js'; } from './persisted-global-config.js';

View File

@ -29,6 +29,17 @@ function mergeProviderOptions(
return Object.keys(result).length > 0 ? result : undefined; return Object.keys(result).length > 0 ? result : undefined;
} }
function resolveMovementProviderOptions(
source: 'env' | 'project' | 'global' | 'default' | undefined,
resolvedConfigOptions: MovementProviderOptions | undefined,
movementOptions: MovementProviderOptions | undefined,
): MovementProviderOptions | undefined {
if (source === 'env' || source === 'project') {
return mergeProviderOptions(movementOptions, resolvedConfigOptions);
}
return mergeProviderOptions(resolvedConfigOptions, movementOptions);
}
export class OptionsBuilder { export class OptionsBuilder {
constructor( constructor(
private readonly engineOptions: PieceEngineOptions, private readonly engineOptions: PieceEngineOptions,
@ -53,11 +64,8 @@ export class OptionsBuilder {
model: this.engineOptions.model, model: this.engineOptions.model,
personaProviders: this.engineOptions.personaProviders, personaProviders: this.engineOptions.personaProviders,
}); });
const resolvedProvider = resolved.provider ?? this.engineOptions.provider ?? 'claude';
const resolvedProviderForPermissions = const resolvedModel = resolved.model ?? this.engineOptions.model;
this.engineOptions.provider
?? resolved.provider
?? 'claude';
return { return {
cwd: this.getCwd(), cwd: this.getCwd(),
@ -65,16 +73,17 @@ export class OptionsBuilder {
personaPath: step.personaPath, personaPath: step.personaPath,
provider: this.engineOptions.provider, provider: this.engineOptions.provider,
model: this.engineOptions.model, model: this.engineOptions.model,
stepProvider: resolved.provider, stepProvider: resolvedProvider,
stepModel: resolved.model, stepModel: resolvedModel,
permissionMode: resolveMovementPermissionMode({ permissionMode: resolveMovementPermissionMode({
movementName: step.name, movementName: step.name,
requiredPermissionMode: step.requiredPermissionMode, requiredPermissionMode: step.requiredPermissionMode,
provider: resolvedProviderForPermissions, provider: resolvedProvider,
projectProviderProfiles: this.engineOptions.providerProfiles, projectProviderProfiles: this.engineOptions.providerProfiles,
globalProviderProfiles: DEFAULT_PROVIDER_PERMISSION_PROFILES, globalProviderProfiles: DEFAULT_PROVIDER_PERMISSION_PROFILES,
}), }),
providerOptions: mergeProviderOptions( providerOptions: resolveMovementProviderOptions(
this.engineOptions.providerOptionsSource,
this.engineOptions.providerOptions, this.engineOptions.providerOptions,
step.providerOptions, step.providerOptions,
), ),

View File

@ -1,12 +1,12 @@
import type { PieceMovement } from '../models/types.js'; import type { PieceMovement } from '../models/types.js';
import type { PersonaProviderEntry } from '../models/persisted-global-config.js';
export type ProviderType = 'claude' | 'codex' | 'opencode' | 'mock'; import type { ProviderType } from './types.js';
export interface MovementProviderModelInput { export interface MovementProviderModelInput {
step: Pick<PieceMovement, 'provider' | 'model' | 'personaDisplayName'>; step: Pick<PieceMovement, 'provider' | 'model' | 'personaDisplayName'>;
provider?: ProviderType; provider?: ProviderType;
model?: string; model?: string;
personaProviders?: Record<string, ProviderType>; personaProviders?: Record<string, PersonaProviderEntry>;
} }
export interface MovementProviderModelOutput { export interface MovementProviderModelOutput {
@ -15,10 +15,11 @@ export interface MovementProviderModelOutput {
} }
export function resolveMovementProviderModel(input: MovementProviderModelInput): MovementProviderModelOutput { export function resolveMovementProviderModel(input: MovementProviderModelInput): MovementProviderModelOutput {
const personaEntry = input.personaProviders?.[input.step.personaDisplayName];
return { return {
provider: input.step.provider provider: input.step.provider
?? input.personaProviders?.[input.step.personaDisplayName] ?? personaEntry?.provider
?? input.provider, ?? input.provider,
model: input.step.model ?? input.model, model: input.step.model ?? personaEntry?.model ?? input.model,
}; };
} }

View File

@ -7,10 +7,12 @@
import type { PermissionResult, PermissionUpdate } from '@anthropic-ai/claude-agent-sdk'; import type { PermissionResult, PermissionUpdate } from '@anthropic-ai/claude-agent-sdk';
import type { PieceMovement, AgentResponse, PieceState, Language, LoopMonitorConfig } from '../models/types.js'; import type { PieceMovement, AgentResponse, PieceState, Language, LoopMonitorConfig } from '../models/types.js';
import type { PersonaProviderEntry } from '../models/persisted-global-config.js';
import type { ProviderPermissionProfiles } from '../models/provider-profiles.js'; import type { ProviderPermissionProfiles } from '../models/provider-profiles.js';
import type { MovementProviderOptions } from '../models/piece-types.js'; import type { MovementProviderOptions } from '../models/piece-types.js';
export type ProviderType = 'claude' | 'codex' | 'opencode' | 'mock'; export type ProviderType = 'claude' | 'codex' | 'opencode' | 'mock';
export type ProviderOptionsSource = 'env' | 'project' | 'global' | 'default';
export interface StreamInitEventData { export interface StreamInitEventData {
model: string; model: string;
@ -182,8 +184,10 @@ export interface PieceEngineOptions {
model?: string; model?: string;
/** Resolved provider options */ /** Resolved provider options */
providerOptions?: MovementProviderOptions; providerOptions?: MovementProviderOptions;
/** Per-persona provider overrides (e.g., { coder: 'codex' }) */ /** Source layer for resolved provider options */
personaProviders?: Record<string, ProviderType>; providerOptionsSource?: ProviderOptionsSource;
/** Per-persona provider and model overrides (e.g., { coder: { provider: 'codex', model: 'o3-mini' } }) */
personaProviders?: Record<string, PersonaProviderEntry>;
/** Resolved provider permission profiles */ /** Resolved provider permission profiles */
providerProfiles?: ProviderPermissionProfiles; providerProfiles?: ProviderPermissionProfiles;
/** Enable interactive-only rules and user-input transitions */ /** Enable interactive-only rules and user-input transitions */

View File

@ -467,6 +467,7 @@ export async function executePiece(
provider: options.provider, provider: options.provider,
model: options.model, model: options.model,
providerOptions: options.providerOptions, providerOptions: options.providerOptions,
providerOptionsSource: options.providerOptionsSource,
personaProviders: options.personaProviders, personaProviders: options.personaProviders,
providerProfiles: options.providerProfiles, providerProfiles: options.providerProfiles,
interactive: interactiveUserInput, interactive: interactiveUserInput,
@ -547,8 +548,9 @@ export async function executePiece(
model: options.model, model: options.model,
personaProviders: options.personaProviders, personaProviders: options.personaProviders,
}); });
const movementProvider = resolved.provider ?? currentProvider; const movementProvider = resolved.provider ?? 'claude';
const movementModel = resolved.model ?? globalConfig.model ?? '(default)'; const resolvedModel = resolved.model;
const movementModel = resolvedModel ?? '(default)';
currentMovementProvider = movementProvider; currentMovementProvider = movementProvider;
currentMovementModel = movementModel; currentMovementModel = movementModel;
providerEventLogger.setMovement(step.name); providerEventLogger.setMovement(step.name);

View File

@ -2,7 +2,7 @@
* Task execution logic * Task execution logic
*/ */
import { loadPieceByIdentifier, isPiecePath, resolvePieceConfigValues } from '../../../infra/config/index.js'; import { loadPieceByIdentifier, isPiecePath, resolveConfigValueWithSource, resolvePieceConfigValues } from '../../../infra/config/index.js';
import { TaskRunner, type TaskInfo } from '../../../infra/task/index.js'; import { TaskRunner, type TaskInfo } from '../../../infra/task/index.js';
import { import {
header, header,
@ -66,16 +66,17 @@ async function executeTaskWithResult(options: ExecuteTaskOptions): Promise<Piece
'language', 'language',
'provider', 'provider',
'model', 'model',
'providerOptions',
'personaProviders', 'personaProviders',
'providerProfiles', 'providerProfiles',
]); ]);
const providerOptions = resolveConfigValueWithSource(projectCwd, 'providerOptions');
return await executePiece(pieceConfig, task, cwd, { return await executePiece(pieceConfig, task, cwd, {
projectCwd, projectCwd,
language: config.language, language: config.language,
provider: agentOverrides?.provider ?? config.provider, provider: agentOverrides?.provider ?? config.provider,
model: agentOverrides?.model ?? config.model, model: agentOverrides?.model ?? config.model,
providerOptions: config.providerOptions, providerOptions: providerOptions.value,
providerOptionsSource: providerOptions.source === 'piece' ? 'global' : providerOptions.source,
personaProviders: config.personaProviders, personaProviders: config.personaProviders,
providerProfiles: config.providerProfiles, providerProfiles: config.providerProfiles,
interactiveUserInput, interactiveUserInput,

View File

@ -3,10 +3,12 @@
*/ */
import type { Language } from '../../../core/models/index.js'; import type { Language } from '../../../core/models/index.js';
import type { PersonaProviderEntry } from '../../../core/models/persisted-global-config.js';
import type { ProviderPermissionProfiles } from '../../../core/models/provider-profiles.js'; import type { ProviderPermissionProfiles } from '../../../core/models/provider-profiles.js';
import type { MovementProviderOptions } from '../../../core/models/piece-types.js'; import type { MovementProviderOptions } from '../../../core/models/piece-types.js';
import type { ProviderType } from '../../../infra/providers/index.js'; import type { ProviderType } from '../../../infra/providers/index.js';
import type { GitHubIssue } from '../../../infra/github/index.js'; import type { GitHubIssue } from '../../../infra/github/index.js';
import type { ProviderOptionsSource } from '../../../core/piece/types.js';
/** Result of piece execution */ /** Result of piece execution */
export interface PieceExecutionResult { export interface PieceExecutionResult {
@ -36,8 +38,10 @@ export interface PieceExecutionOptions {
model?: string; model?: string;
/** Resolved provider options */ /** Resolved provider options */
providerOptions?: MovementProviderOptions; providerOptions?: MovementProviderOptions;
/** Per-persona provider overrides (e.g., { coder: 'codex' }) */ /** Source layer for resolved provider options */
personaProviders?: Record<string, ProviderType>; providerOptionsSource?: ProviderOptionsSource;
/** Per-persona provider and model overrides (e.g., { coder: { provider: 'codex', model: 'o3-mini' } }) */
personaProviders?: Record<string, PersonaProviderEntry>;
/** Resolved provider permission profiles */ /** Resolved provider permission profiles */
providerProfiles?: ProviderPermissionProfiles; providerProfiles?: ProviderPermissionProfiles;
/** Enable interactive user input during step transitions */ /** Enable interactive user input during step transitions */

View File

@ -29,9 +29,6 @@ export {
isPiecePath, isPiecePath,
} from './infra/config/loaders/index.js'; } from './infra/config/loaders/index.js';
export type { PieceSource, PieceWithSource, PieceDirEntry } from './infra/config/loaders/index.js'; export type { PieceSource, PieceWithSource, PieceDirEntry } from './infra/config/loaders/index.js';
export {
loadConfig,
} from './infra/config/loadConfig.js';
export { export {
saveProjectConfig, saveProjectConfig,
updateProjectConfig, updateProjectConfig,

View File

@ -9,13 +9,15 @@ import { readFileSync, existsSync, writeFileSync, statSync, accessSync, constant
import { isAbsolute } from 'node:path'; import { isAbsolute } from 'node:path';
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'; import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
import { GlobalConfigSchema } from '../../../core/models/index.js'; import { GlobalConfigSchema } from '../../../core/models/index.js';
import type { GlobalConfig, Language } from '../../../core/models/index.js'; import type { Language } from '../../../core/models/index.js';
import type { PersistedGlobalConfig, PersonaProviderEntry } from '../../../core/models/persisted-global-config.js';
import type { ProviderPermissionProfiles } from '../../../core/models/provider-profiles.js'; import type { ProviderPermissionProfiles } from '../../../core/models/provider-profiles.js';
import { normalizeProviderOptions } from '../loaders/pieceParser.js'; import { normalizeProviderOptions } from '../loaders/pieceParser.js';
import { getGlobalConfigPath } from '../paths.js'; import { getGlobalConfigPath } from '../paths.js';
import { DEFAULT_LANGUAGE } from '../../../shared/constants.js'; import { DEFAULT_LANGUAGE } from '../../../shared/constants.js';
import { parseProviderModel } from '../../../shared/utils/providerModel.js'; import { parseProviderModel } from '../../../shared/utils/providerModel.js';
import { applyGlobalConfigEnvOverrides, envVarNameFromPath } from '../env/config-env-overrides.js'; import { applyGlobalConfigEnvOverrides, envVarNameFromPath } from '../env/config-env-overrides.js';
import { invalidateAllResolvedConfigCache } from '../resolutionCache.js';
/** Claude-specific model aliases that are not valid for other providers */ /** Claude-specific model aliases that are not valid for other providers */
const CLAUDE_MODEL_ALIASES = new Set(['opus', 'sonnet', 'haiku']); const CLAUDE_MODEL_ALIASES = new Set(['opus', 'sonnet', 'haiku']);
@ -56,7 +58,6 @@ function validateCodexCliPath(pathValue: string, sourceName: 'TAKT_CODEX_CLI_PAT
return trimmed; return trimmed;
} }
/** Validate that provider and model are compatible */
function validateProviderModelCompatibility(provider: string | undefined, model: string | undefined): void { function validateProviderModelCompatibility(provider: string | undefined, model: string | undefined): void {
if (!provider) return; if (!provider) return;
@ -80,6 +81,19 @@ function validateProviderModelCompatibility(provider: string | undefined, model:
} }
} }
function normalizePersonaProviders(
raw: Record<string, NonNullable<PersonaProviderEntry['provider']> | PersonaProviderEntry> | undefined,
): Record<string, PersonaProviderEntry> | undefined {
if (!raw) return undefined;
return Object.fromEntries(
Object.entries(raw).map(([persona, entry]) => {
const normalized: PersonaProviderEntry = typeof entry === 'string' ? { provider: entry } : entry;
validateProviderModelCompatibility(normalized.provider, normalized.model);
return [persona, normalized];
}),
);
}
function normalizeProviderProfiles( function normalizeProviderProfiles(
raw: Record<string, { default_permission_mode: unknown; movement_permission_overrides?: Record<string, unknown> }> | undefined, raw: Record<string, { default_permission_mode: unknown; movement_permission_overrides?: Record<string, unknown> }> | undefined,
): ProviderPermissionProfiles | undefined { ): ProviderPermissionProfiles | undefined {
@ -114,7 +128,7 @@ function denormalizeProviderProfiles(
*/ */
export class GlobalConfigManager { export class GlobalConfigManager {
private static instance: GlobalConfigManager | null = null; private static instance: GlobalConfigManager | null = null;
private cachedConfig: GlobalConfig | null = null; private cachedConfig: PersistedGlobalConfig | null = null;
private constructor() {} private constructor() {}
@ -136,7 +150,7 @@ export class GlobalConfigManager {
} }
/** Load global configuration (cached) */ /** Load global configuration (cached) */
load(): GlobalConfig { load(): PersistedGlobalConfig {
if (this.cachedConfig !== null) { if (this.cachedConfig !== null) {
return this.cachedConfig; return this.cachedConfig;
} }
@ -156,7 +170,7 @@ export class GlobalConfigManager {
applyGlobalConfigEnvOverrides(rawConfig); applyGlobalConfigEnvOverrides(rawConfig);
const parsed = GlobalConfigSchema.parse(rawConfig); const parsed = GlobalConfigSchema.parse(rawConfig);
const config: GlobalConfig = { const config: PersistedGlobalConfig = {
language: parsed.language, language: parsed.language,
logLevel: parsed.log_level, logLevel: parsed.log_level,
provider: parsed.provider, provider: parsed.provider,
@ -186,7 +200,7 @@ export class GlobalConfigManager {
minimalOutput: parsed.minimal_output, minimalOutput: parsed.minimal_output,
bookmarksFile: parsed.bookmarks_file, bookmarksFile: parsed.bookmarks_file,
pieceCategoriesFile: parsed.piece_categories_file, pieceCategoriesFile: parsed.piece_categories_file,
personaProviders: parsed.persona_providers, personaProviders: normalizePersonaProviders(parsed.persona_providers as Record<string, NonNullable<PersonaProviderEntry['provider']> | PersonaProviderEntry> | undefined),
providerOptions: normalizeProviderOptions(parsed.provider_options), providerOptions: normalizeProviderOptions(parsed.provider_options),
providerProfiles: normalizeProviderProfiles(parsed.provider_profiles as Record<string, { default_permission_mode: unknown; movement_permission_overrides?: Record<string, unknown> }> | undefined), providerProfiles: normalizeProviderProfiles(parsed.provider_profiles as Record<string, { default_permission_mode: unknown; movement_permission_overrides?: Record<string, unknown> }> | undefined),
runtime: parsed.runtime?.prepare && parsed.runtime.prepare.length > 0 runtime: parsed.runtime?.prepare && parsed.runtime.prepare.length > 0
@ -213,7 +227,7 @@ export class GlobalConfigManager {
} }
/** Save global configuration to disk and invalidate cache */ /** Save global configuration to disk and invalidate cache */
save(config: GlobalConfig): void { save(config: PersistedGlobalConfig): void {
const configPath = getGlobalConfigPath(); const configPath = getGlobalConfigPath();
const raw: Record<string, unknown> = { const raw: Record<string, unknown> = {
language: config.language, language: config.language,
@ -338,18 +352,20 @@ export class GlobalConfigManager {
} }
writeFileSync(configPath, stringifyYaml(raw), 'utf-8'); writeFileSync(configPath, stringifyYaml(raw), 'utf-8');
this.invalidateCache(); this.invalidateCache();
invalidateAllResolvedConfigCache();
} }
} }
export function invalidateGlobalConfigCache(): void { export function invalidateGlobalConfigCache(): void {
GlobalConfigManager.getInstance().invalidateCache(); GlobalConfigManager.getInstance().invalidateCache();
invalidateAllResolvedConfigCache();
} }
export function loadGlobalConfig(): GlobalConfig { export function loadGlobalConfig(): PersistedGlobalConfig {
return GlobalConfigManager.getInstance().load(); return GlobalConfigManager.getInstance().load();
} }
export function saveGlobalConfig(config: GlobalConfig): void { export function saveGlobalConfig(config: PersistedGlobalConfig): void {
GlobalConfigManager.getInstance().save(config); GlobalConfigManager.getInstance().save(config);
} }
@ -434,7 +450,7 @@ export function resolveCodexCliPath(): string | undefined {
return validateCodexCliPath(envPath, 'TAKT_CODEX_CLI_PATH'); return validateCodexCliPath(envPath, 'TAKT_CODEX_CLI_PATH');
} }
let config: GlobalConfig; let config: PersistedGlobalConfig;
try { try {
config = loadGlobalConfig(); config = loadGlobalConfig();
} catch { } catch {

View File

@ -1,131 +0,0 @@
import type { GlobalConfig } from '../../core/models/index.js';
import type { MovementProviderOptions } from '../../core/models/piece-types.js';
import type { ProviderPermissionProfiles } from '../../core/models/provider-profiles.js';
import type { AnalyticsConfig } from '../../core/models/global-config.js';
import { loadGlobalConfig } from './global/globalConfig.js';
import { loadProjectConfig } from './project/projectConfig.js';
import { envVarNameFromPath } from './env/config-env-overrides.js';
export interface LoadedConfig extends GlobalConfig {
piece: string;
provider: NonNullable<GlobalConfig['provider']>;
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 ?? 'claude') as NonNullable<GlobalConfig['provider']>;
return {
...global,
piece: project.piece ?? 'default',
provider,
autoPr: project.auto_pr ?? global.autoPr,
draftPr: project.draft_pr ?? global.draftPr,
model: resolveModel(global, provider),
verbose: resolveVerbose(project.verbose, global.verbose),
analytics: mergeAnalytics(global.analytics, project.analytics),
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 mergeAnalytics(
globalAnalytics: AnalyticsConfig | undefined,
projectAnalytics: AnalyticsConfig | undefined,
): AnalyticsConfig | undefined {
if (!globalAnalytics && !projectAnalytics) return undefined;
const merged: AnalyticsConfig = {
enabled: projectAnalytics?.enabled ?? globalAnalytics?.enabled,
eventsPath: projectAnalytics?.eventsPath ?? globalAnalytics?.eventsPath,
retentionDays: projectAnalytics?.retentionDays ?? globalAnalytics?.retentionDays,
};
if (merged.enabled === undefined && merged.eventsPath === undefined && merged.retentionDays === undefined) {
return undefined;
}
return merged;
}
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

@ -10,9 +10,10 @@ import { parse, stringify } from 'yaml';
import { copyProjectResourcesToDir } from '../../resources/index.js'; import { copyProjectResourcesToDir } from '../../resources/index.js';
import type { ProjectLocalConfig } from '../types.js'; import type { ProjectLocalConfig } from '../types.js';
import type { ProviderPermissionProfiles } from '../../../core/models/provider-profiles.js'; import type { ProviderPermissionProfiles } from '../../../core/models/provider-profiles.js';
import type { AnalyticsConfig } from '../../../core/models/global-config.js'; import type { AnalyticsConfig } from '../../../core/models/persisted-global-config.js';
import { applyProjectConfigEnvOverrides } from '../env/config-env-overrides.js'; import { applyProjectConfigEnvOverrides } from '../env/config-env-overrides.js';
import { normalizeProviderOptions } from '../loaders/pieceParser.js'; import { normalizeProviderOptions } from '../loaders/pieceParser.js';
import { invalidateResolvedConfigCache } from '../resolutionCache.js';
export type { ProjectLocalConfig } from '../types.js'; export type { ProjectLocalConfig } from '../types.js';
@ -154,6 +155,7 @@ export function saveProjectConfig(projectDir: string, config: ProjectLocalConfig
const content = stringify(savePayload, { indent: 2 }); const content = stringify(savePayload, { indent: 2 });
writeFileSync(configPath, content, 'utf-8'); writeFileSync(configPath, content, 'utf-8');
invalidateResolvedConfigCache(projectDir);
} }
/** /**

View File

@ -1,32 +1,5 @@
import { envVarNameFromPath } from '../env/config-env-overrides.js'; import { resolveConfigValue } from '../resolveConfigValue.js';
import { loadConfig } from '../loadConfig.js';
function resolveValue<T>(
envValue: T | undefined,
localValue: T | undefined,
globalValue: T | undefined,
defaultValue: T,
): T {
if (envValue !== undefined) return envValue;
if (localValue !== undefined) return localValue;
if (globalValue !== undefined) return globalValue;
return defaultValue;
}
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`);
}
export function isVerboseMode(projectDir: string): boolean { export function isVerboseMode(projectDir: string): boolean {
const envValue = loadEnvBooleanSetting('verbose'); return resolveConfigValue(projectDir, 'verbose');
const config = loadConfig(projectDir);
return resolveValue(envValue, undefined, config.verbose, false);
} }

View File

@ -0,0 +1,50 @@
import { resolve } from 'node:path';
import type { ProjectLocalConfig } from './types.js';
import type { ConfigParameterKey } from './resolvedConfig.js';
const projectConfigCache = new Map<string, ProjectLocalConfig>();
const resolvedValueCache = new Map<string, unknown>();
function normalizeProjectDir(projectDir: string): string {
return resolve(projectDir);
}
function resolvedValueKey(projectDir: string, key: ConfigParameterKey): string {
return `${normalizeProjectDir(projectDir)}::${key}`;
}
export function getCachedProjectConfig(projectDir: string): ProjectLocalConfig | undefined {
return projectConfigCache.get(normalizeProjectDir(projectDir));
}
export function setCachedProjectConfig(projectDir: string, config: ProjectLocalConfig): void {
projectConfigCache.set(normalizeProjectDir(projectDir), config);
}
export function hasCachedResolvedValue(projectDir: string, key: ConfigParameterKey): boolean {
return resolvedValueCache.has(resolvedValueKey(projectDir, key));
}
export function getCachedResolvedValue(projectDir: string, key: ConfigParameterKey): unknown {
return resolvedValueCache.get(resolvedValueKey(projectDir, key));
}
export function setCachedResolvedValue(projectDir: string, key: ConfigParameterKey, value: unknown): void {
resolvedValueCache.set(resolvedValueKey(projectDir, key), value);
}
export function invalidateResolvedConfigCache(projectDir: string): void {
const normalizedProjectDir = normalizeProjectDir(projectDir);
projectConfigCache.delete(normalizedProjectDir);
const prefix = `${normalizedProjectDir}::`;
for (const key of resolvedValueCache.keys()) {
if (key.startsWith(prefix)) {
resolvedValueCache.delete(key);
}
}
}
export function invalidateAllResolvedConfigCache(): void {
projectConfigCache.clear();
resolvedValueCache.clear();
}

View File

@ -1,22 +1,230 @@
import { loadConfig, type LoadedConfig } from './loadConfig.js'; import { loadGlobalConfig } from './global/globalConfig.js';
import { loadProjectConfig } from './project/projectConfig.js';
import { envVarNameFromPath } from './env/config-env-overrides.js';
import {
getCachedProjectConfig,
getCachedResolvedValue,
hasCachedResolvedValue,
setCachedProjectConfig,
setCachedResolvedValue,
} from './resolutionCache.js';
import type { ConfigParameterKey, LoadedConfig } from './resolvedConfig.js';
export type ConfigParameterKey = keyof LoadedConfig; export type { ConfigParameterKey } from './resolvedConfig.js';
export { invalidateResolvedConfigCache, invalidateAllResolvedConfigCache } from './resolutionCache.js';
export interface PieceContext {
provider?: LoadedConfig['provider'];
model?: LoadedConfig['model'];
providerOptions?: LoadedConfig['providerOptions'];
}
export interface ResolveConfigOptions {
pieceContext?: PieceContext;
}
export type ConfigValueSource = 'env' | 'project' | 'piece' | 'global' | 'default';
export interface ResolvedConfigValue<K extends ConfigParameterKey> {
value: LoadedConfig[K];
source: ConfigValueSource;
}
type ResolutionLayer = 'local' | 'piece' | 'global';
interface ResolutionRule<K extends ConfigParameterKey> {
layers: readonly ResolutionLayer[];
defaultValue?: LoadedConfig[K];
mergeMode?: 'analytics';
pieceValue?: (pieceContext: PieceContext | undefined) => LoadedConfig[K] | undefined;
}
function loadProjectConfigCached(projectDir: string) {
const cached = getCachedProjectConfig(projectDir);
if (cached !== undefined) {
return cached;
}
const loaded = loadProjectConfig(projectDir);
setCachedProjectConfig(projectDir, loaded);
return loaded;
}
const DEFAULT_RULE: ResolutionRule<ConfigParameterKey> = {
layers: ['local', 'global'],
};
const PROVIDER_OPTIONS_ENV_PATHS = [
'provider_options',
'provider_options.codex.network_access',
'provider_options.opencode.network_access',
'provider_options.claude.sandbox.allow_unsandboxed_commands',
'provider_options.claude.sandbox.excluded_commands',
] as const;
const RESOLUTION_REGISTRY: Partial<{ [K in ConfigParameterKey]: ResolutionRule<K> }> = {
piece: { layers: ['local', 'global'], defaultValue: 'default' },
provider: {
layers: ['local', 'piece', 'global'],
defaultValue: 'claude',
pieceValue: (pieceContext) => pieceContext?.provider,
},
model: {
layers: ['local', 'piece', 'global'],
pieceValue: (pieceContext) => pieceContext?.model,
},
providerOptions: {
layers: ['local', 'piece', 'global'],
pieceValue: (pieceContext) => pieceContext?.providerOptions,
},
autoPr: { layers: ['local', 'global'] },
draftPr: { layers: ['local', 'global'] },
analytics: { layers: ['local', 'global'], mergeMode: 'analytics' },
verbose: { layers: ['local', 'global'], defaultValue: false },
};
function resolveAnalyticsMerged(
project: ReturnType<typeof loadProjectConfigCached>,
global: ReturnType<typeof loadGlobalConfig>,
): LoadedConfig['analytics'] {
const localAnalytics = project.analytics;
const globalAnalytics = global.analytics;
const enabled = localAnalytics?.enabled ?? globalAnalytics?.enabled;
const eventsPath = localAnalytics?.eventsPath ?? globalAnalytics?.eventsPath;
const retentionDays = localAnalytics?.retentionDays ?? globalAnalytics?.retentionDays;
if (enabled === undefined && eventsPath === undefined && retentionDays === undefined) {
return undefined;
}
return { enabled, eventsPath, retentionDays };
}
function resolveAnalyticsSource(
project: ReturnType<typeof loadProjectConfigCached>,
global: ReturnType<typeof loadGlobalConfig>,
): ConfigValueSource {
if (project.analytics !== undefined) return 'project';
if (global.analytics !== undefined) return 'global';
return 'default';
}
function getLocalLayerValue<K extends ConfigParameterKey>(
project: ReturnType<typeof loadProjectConfigCached>,
key: K,
): LoadedConfig[K] | undefined {
switch (key) {
case 'piece':
return project.piece as LoadedConfig[K] | undefined;
case 'provider':
return project.provider as LoadedConfig[K] | undefined;
case 'autoPr':
return project.auto_pr as LoadedConfig[K] | undefined;
case 'draftPr':
return project.draft_pr as LoadedConfig[K] | undefined;
case 'verbose':
return project.verbose as LoadedConfig[K] | undefined;
case 'analytics':
return project.analytics as LoadedConfig[K] | undefined;
case 'providerOptions':
return project.providerOptions as LoadedConfig[K] | undefined;
case 'providerProfiles':
return project.providerProfiles as LoadedConfig[K] | undefined;
default:
return undefined;
}
}
function getGlobalLayerValue<K extends ConfigParameterKey>(
global: ReturnType<typeof loadGlobalConfig>,
key: K,
): LoadedConfig[K] | undefined {
return global[key as keyof typeof global] as LoadedConfig[K] | undefined;
}
function resolveByRegistry<K extends ConfigParameterKey>(
key: K,
project: ReturnType<typeof loadProjectConfigCached>,
global: ReturnType<typeof loadGlobalConfig>,
options: ResolveConfigOptions | undefined,
): ResolvedConfigValue<K> {
const rule = (RESOLUTION_REGISTRY[key] ?? DEFAULT_RULE) as ResolutionRule<K>;
if (rule.mergeMode === 'analytics') {
return {
value: resolveAnalyticsMerged(project, global) as LoadedConfig[K],
source: resolveAnalyticsSource(project, global),
};
}
for (const layer of rule.layers) {
let value: LoadedConfig[K] | undefined;
if (layer === 'local') {
value = getLocalLayerValue(project, key);
} else if (layer === 'piece') {
value = rule.pieceValue?.(options?.pieceContext);
} else {
value = getGlobalLayerValue(global, key);
}
if (value !== undefined) {
if (layer === 'local') {
if (key === 'providerOptions' && hasProviderOptionsEnvOverride()) {
return { value, source: 'env' };
}
return { value, source: 'project' };
}
if (layer === 'piece') {
return { value, source: 'piece' };
}
return { value, source: 'global' };
}
}
return { value: rule.defaultValue as LoadedConfig[K], source: 'default' };
}
function hasProviderOptionsEnvOverride(): boolean {
return PROVIDER_OPTIONS_ENV_PATHS.some((path) => process.env[envVarNameFromPath(path)] !== undefined);
}
function resolveUncachedConfigValue<K extends ConfigParameterKey>(
projectDir: string,
key: K,
options?: ResolveConfigOptions,
): ResolvedConfigValue<K> {
const project = loadProjectConfigCached(projectDir);
const global = loadGlobalConfig();
return resolveByRegistry(key, project, global, options);
}
export function resolveConfigValueWithSource<K extends ConfigParameterKey>(
projectDir: string,
key: K,
options?: ResolveConfigOptions,
): ResolvedConfigValue<K> {
const resolved = resolveUncachedConfigValue(projectDir, key, options);
if (!options?.pieceContext) {
setCachedResolvedValue(projectDir, key, resolved.value);
}
return resolved;
}
export function resolveConfigValue<K extends ConfigParameterKey>( export function resolveConfigValue<K extends ConfigParameterKey>(
projectDir: string, projectDir: string,
key: K, key: K,
options?: ResolveConfigOptions,
): LoadedConfig[K] { ): LoadedConfig[K] {
return loadConfig(projectDir)[key]; if (!options?.pieceContext && hasCachedResolvedValue(projectDir, key)) {
return getCachedResolvedValue(projectDir, key) as LoadedConfig[K];
}
return resolveConfigValueWithSource(projectDir, key, options).value;
} }
export function resolveConfigValues<K extends ConfigParameterKey>( export function resolveConfigValues<K extends ConfigParameterKey>(
projectDir: string, projectDir: string,
keys: readonly K[], keys: readonly K[],
options?: ResolveConfigOptions,
): Pick<LoadedConfig, K> { ): Pick<LoadedConfig, K> {
const config = loadConfig(projectDir);
const result = {} as Pick<LoadedConfig, K>; const result = {} as Pick<LoadedConfig, K>;
for (const key of keys) { for (const key of keys) {
result[key] = config[key]; result[key] = resolveConfigValue(projectDir, key, options);
} }
return result; return result;
} }

View File

@ -1,17 +1,20 @@
import type { ConfigParameterKey } from './resolveConfigValue.js'; import type { ConfigParameterKey } from './resolveConfigValue.js';
import { resolveConfigValue, resolveConfigValues } from './resolveConfigValue.js'; import { resolveConfigValue, resolveConfigValues } from './resolveConfigValue.js';
import type { LoadedConfig } from './loadConfig.js'; import type { ResolveConfigOptions } from './resolveConfigValue.js';
import type { LoadedConfig } from './resolvedConfig.js';
export function resolvePieceConfigValue<K extends ConfigParameterKey>( export function resolvePieceConfigValue<K extends ConfigParameterKey>(
projectDir: string, projectDir: string,
key: K, key: K,
options?: ResolveConfigOptions,
): LoadedConfig[K] { ): LoadedConfig[K] {
return resolveConfigValue(projectDir, key); return resolveConfigValue(projectDir, key, options);
} }
export function resolvePieceConfigValues<K extends ConfigParameterKey>( export function resolvePieceConfigValues<K extends ConfigParameterKey>(
projectDir: string, projectDir: string,
keys: readonly K[], keys: readonly K[],
options?: ResolveConfigOptions,
): Pick<LoadedConfig, K> { ): Pick<LoadedConfig, K> {
return resolveConfigValues(projectDir, keys); return resolveConfigValues(projectDir, keys, options);
} }

View File

@ -0,0 +1,9 @@
import type { PersistedGlobalConfig } from '../../core/models/persisted-global-config.js';
export interface LoadedConfig extends Omit<PersistedGlobalConfig, 'provider' | 'verbose'> {
piece: string;
provider: NonNullable<PersistedGlobalConfig['provider']>;
verbose: boolean;
}
export type ConfigParameterKey = keyof LoadedConfig;

View File

@ -4,7 +4,7 @@
import type { MovementProviderOptions } from '../../core/models/piece-types.js'; import type { MovementProviderOptions } from '../../core/models/piece-types.js';
import type { ProviderPermissionProfiles } from '../../core/models/provider-profiles.js'; import type { ProviderPermissionProfiles } from '../../core/models/provider-profiles.js';
import type { AnalyticsConfig } from '../../core/models/global-config.js'; import type { AnalyticsConfig } from '../../core/models/persisted-global-config.js';
/** Project configuration stored in .takt/config.yaml */ /** Project configuration stored in .takt/config.yaml */
export interface ProjectLocalConfig { export interface ProjectLocalConfig {