diff --git a/src/__tests__/cli-routing-issue-resolve.test.ts b/src/__tests__/cli-routing-issue-resolve.test.ts index b29af62..72b99b3 100644 --- a/src/__tests__/cli-routing-issue-resolve.test.ts +++ b/src/__tests__/cli-routing-issue-resolve.test.ts @@ -76,7 +76,7 @@ vi.mock('../infra/task/index.js', () => ({ vi.mock('../infra/config/index.js', () => ({ getPieceDescription: vi.fn(() => ({ name: 'default', description: 'test piece', pieceStructure: '', movementPreviews: [] })), - loadGlobalConfig: vi.fn(() => ({ interactivePreviewMovements: 3 })), + loadConfig: vi.fn(() => ({ global: { interactivePreviewMovements: 3 }, project: {} })), })); 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 { loadGlobalConfig } from '../infra/config/index.js'; +import { loadConfig } 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 mockLoadGlobalConfig = vi.mocked(loadGlobalConfig); +const mockLoadConfig = vi.mocked(loadConfig); 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 - mockLoadGlobalConfig.mockReturnValue({ interactivePreviewMovements: 3, provider: 'claude' }); + mockLoadConfig.mockReturnValue({ global: { interactivePreviewMovements: 3, provider: 'claude' }, project: {} }); 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 - mockLoadGlobalConfig.mockReturnValue({ interactivePreviewMovements: 3, provider: 'claude' }); + mockLoadConfig.mockReturnValue({ global: { interactivePreviewMovements: 3, provider: 'claude' }, project: {} }); 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 - mockLoadGlobalConfig.mockReturnValue({ interactivePreviewMovements: 3, provider: 'openai' }); + mockLoadConfig.mockReturnValue({ global: { interactivePreviewMovements: 3, provider: 'openai' }, project: {} }); // When await executeDefaultAction(); diff --git a/src/__tests__/config-env-overrides.test.ts b/src/__tests__/config-env-overrides.test.ts new file mode 100644 index 0000000..3a2ce1a --- /dev/null +++ b/src/__tests__/config-env-overrides.test.ts @@ -0,0 +1,53 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import { + applyGlobalConfigEnvOverrides, + applyProjectConfigEnvOverrides, + envVarNameFromPath, +} from '../infra/config/env/config-env-overrides.js'; + +describe('config env overrides', () => { + const envBackup = { ...process.env }; + + afterEach(() => { + for (const key of Object.keys(process.env)) { + if (!(key in envBackup)) { + delete process.env[key]; + } + } + for (const [key, value] of Object.entries(envBackup)) { + process.env[key] = value; + } + }); + + it('should convert dotted and camelCase paths to TAKT env variable names', () => { + expect(envVarNameFromPath('verbose')).toBe('TAKT_VERBOSE'); + expect(envVarNameFromPath('provider_options.claude.sandbox.allow_unsandboxed_commands')) + .toBe('TAKT_PROVIDER_OPTIONS_CLAUDE_SANDBOX_ALLOW_UNSANDBOXED_COMMANDS'); + }); + + it('should apply global env overrides from generated env names', () => { + process.env.TAKT_LOG_LEVEL = 'debug'; + process.env.TAKT_PROVIDER_OPTIONS_CLAUDE_SANDBOX_ALLOW_UNSANDBOXED_COMMANDS = 'true'; + + const raw: Record = {}; + applyGlobalConfigEnvOverrides(raw); + + expect(raw.log_level).toBe('debug'); + expect(raw.provider_options).toEqual({ + claude: { + sandbox: { + allow_unsandboxed_commands: true, + }, + }, + }); + }); + + it('should apply project env overrides from generated env names', () => { + process.env.TAKT_VERBOSE = 'true'; + + const raw: Record = {}; + applyProjectConfigEnvOverrides(raw); + + expect(raw.verbose).toBe(true); + }); +}); diff --git a/src/__tests__/config.test.ts b/src/__tests__/config.test.ts index 012ec05..492cfd2 100644 --- a/src/__tests__/config.test.ts +++ b/src/__tests__/config.test.ts @@ -35,6 +35,8 @@ import { updateWorktreeSession, getLanguage, loadProjectConfig, + isVerboseMode, + invalidateGlobalConfigCache, } from '../infra/config/index.js'; describe('getBuiltinPiece', () => { @@ -377,6 +379,154 @@ describe('setCurrentPiece', () => { }); }); +describe('loadProjectConfig provider_options', () => { + let testDir: string; + + beforeEach(() => { + testDir = join(tmpdir(), `takt-test-${randomUUID()}`); + mkdirSync(testDir, { recursive: true }); + }); + + afterEach(() => { + if (existsSync(testDir)) { + rmSync(testDir, { recursive: true, force: true }); + } + }); + + it('should normalize provider_options into providerOptions (camelCase)', () => { + const projectConfigDir = getProjectConfigDir(testDir); + mkdirSync(projectConfigDir, { recursive: true }); + writeFileSync(join(projectConfigDir, 'config.yaml'), [ + 'piece: default', + 'provider_options:', + ' codex:', + ' network_access: true', + ' claude:', + ' sandbox:', + ' allow_unsandboxed_commands: true', + ].join('\n')); + + const config = loadProjectConfig(testDir); + + expect(config.providerOptions).toEqual({ + codex: { networkAccess: true }, + claude: { sandbox: { allowUnsandboxedCommands: true } }, + }); + }); + + it('should apply TAKT_PROVIDER_OPTIONS_* env overrides for project config', () => { + const original = process.env.TAKT_PROVIDER_OPTIONS_CODEX_NETWORK_ACCESS; + process.env.TAKT_PROVIDER_OPTIONS_CODEX_NETWORK_ACCESS = 'false'; + + const config = loadProjectConfig(testDir); + expect(config.providerOptions).toEqual({ + codex: { networkAccess: false }, + }); + + if (original === undefined) { + delete process.env.TAKT_PROVIDER_OPTIONS_CODEX_NETWORK_ACCESS; + } else { + process.env.TAKT_PROVIDER_OPTIONS_CODEX_NETWORK_ACCESS = original; + } + }); +}); + +describe('isVerboseMode', () => { + let testDir: string; + let originalTaktConfigDir: string | undefined; + let originalTaktVerbose: string | undefined; + + beforeEach(() => { + testDir = join(tmpdir(), `takt-test-${randomUUID()}`); + mkdirSync(testDir, { recursive: true }); + originalTaktConfigDir = process.env.TAKT_CONFIG_DIR; + originalTaktVerbose = process.env.TAKT_VERBOSE; + process.env.TAKT_CONFIG_DIR = join(testDir, 'global-takt'); + delete process.env.TAKT_VERBOSE; + invalidateGlobalConfigCache(); + }); + + afterEach(() => { + if (originalTaktConfigDir === undefined) { + delete process.env.TAKT_CONFIG_DIR; + } else { + process.env.TAKT_CONFIG_DIR = originalTaktConfigDir; + } + if (originalTaktVerbose === undefined) { + delete process.env.TAKT_VERBOSE; + } else { + process.env.TAKT_VERBOSE = originalTaktVerbose; + } + + if (existsSync(testDir)) { + rmSync(testDir, { recursive: true, force: true }); + } + }); + + it('should return project verbose when project config has verbose: true', () => { + const projectConfigDir = getProjectConfigDir(testDir); + mkdirSync(projectConfigDir, { recursive: true }); + writeFileSync(join(projectConfigDir, 'config.yaml'), 'piece: default\nverbose: true\n'); + + const globalConfigDir = process.env.TAKT_CONFIG_DIR!; + mkdirSync(globalConfigDir, { recursive: true }); + writeFileSync(join(globalConfigDir, 'config.yaml'), 'verbose: false\n'); + + expect(isVerboseMode(testDir)).toBe(true); + }); + + it('should return project verbose when project config has verbose: false', () => { + const projectConfigDir = getProjectConfigDir(testDir); + mkdirSync(projectConfigDir, { recursive: true }); + writeFileSync(join(projectConfigDir, 'config.yaml'), 'piece: default\nverbose: false\n'); + + const globalConfigDir = process.env.TAKT_CONFIG_DIR!; + mkdirSync(globalConfigDir, { recursive: true }); + writeFileSync(join(globalConfigDir, 'config.yaml'), 'verbose: true\n'); + + expect(isVerboseMode(testDir)).toBe(false); + }); + + it('should fallback to global verbose when project verbose is not set', () => { + const projectConfigDir = getProjectConfigDir(testDir); + mkdirSync(projectConfigDir, { recursive: true }); + writeFileSync(join(projectConfigDir, 'config.yaml'), 'piece: default\n'); + + const globalConfigDir = process.env.TAKT_CONFIG_DIR!; + mkdirSync(globalConfigDir, { recursive: true }); + writeFileSync(join(globalConfigDir, 'config.yaml'), 'verbose: true\n'); + + expect(isVerboseMode(testDir)).toBe(true); + }); + + it('should return false when neither project nor global verbose is set', () => { + expect(isVerboseMode(testDir)).toBe(false); + }); + + it('should prioritize TAKT_VERBOSE over project and global config', () => { + const projectConfigDir = getProjectConfigDir(testDir); + mkdirSync(projectConfigDir, { recursive: true }); + writeFileSync(join(projectConfigDir, 'config.yaml'), 'piece: default\nverbose: false\n'); + + const globalConfigDir = process.env.TAKT_CONFIG_DIR!; + mkdirSync(globalConfigDir, { recursive: true }); + writeFileSync(join(globalConfigDir, 'config.yaml'), 'verbose: false\n'); + + process.env.TAKT_VERBOSE = 'true'; + expect(isVerboseMode(testDir)).toBe(true); + }); + + it('should throw on TAKT_VERBOSE=0', () => { + process.env.TAKT_VERBOSE = '0'; + expect(() => isVerboseMode(testDir)).toThrow('TAKT_VERBOSE must be one of: true, false'); + }); + + it('should throw on invalid TAKT_VERBOSE value', () => { + process.env.TAKT_VERBOSE = 'yes'; + expect(() => isVerboseMode(testDir)).toThrow('TAKT_VERBOSE must be one of: true, false'); + }); +}); + describe('loadInputHistory', () => { let testDir: string; diff --git a/src/__tests__/engine-provider-options.test.ts b/src/__tests__/engine-provider-options.test.ts new file mode 100644 index 0000000..24cab26 --- /dev/null +++ b/src/__tests__/engine-provider-options.test.ts @@ -0,0 +1,138 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { rmSync } from 'node:fs'; + +vi.mock('../agents/runner.js', () => ({ + runAgent: vi.fn(), +})); + +vi.mock('../core/piece/evaluation/index.js', () => ({ + detectMatchedRule: vi.fn(), +})); + +vi.mock('../core/piece/phase-runner.js', () => ({ + needsStatusJudgmentPhase: vi.fn(), + runReportPhase: vi.fn(), + runStatusJudgmentPhase: vi.fn(), +})); + +vi.mock('../shared/utils/index.js', async (importOriginal) => ({ + ...(await importOriginal>()), + generateReportDir: vi.fn().mockReturnValue('test-report-dir'), +})); + +import { PieceEngine } from '../core/piece/index.js'; +import { runAgent } from '../agents/runner.js'; +import { + applyDefaultMocks, + cleanupPieceEngine, + createTestTmpDir, + makeMovement, + makeResponse, + makeRule, + mockDetectMatchedRuleSequence, + mockRunAgentSequence, +} from './engine-test-helpers.js'; +import type { PieceConfig } from '../core/models/index.js'; + +describe('PieceEngine provider_options resolution', () => { + let tmpDir: string; + let engine: PieceEngine | undefined; + + beforeEach(() => { + vi.resetAllMocks(); + applyDefaultMocks(); + tmpDir = createTestTmpDir(); + }); + + afterEach(() => { + if (engine) { + cleanupPieceEngine(engine); + engine = undefined; + } + if (tmpDir) { + rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it('should merge provider_options in order: global < project < movement', async () => { + const movement = makeMovement('implement', { + providerOptions: { + codex: { networkAccess: false }, + claude: { sandbox: { excludedCommands: ['./gradlew'] } }, + }, + rules: [makeRule('done', 'COMPLETE')], + }); + + const config: PieceConfig = { + name: 'provider-options-priority', + movements: [movement], + initialMovement: 'implement', + maxMovements: 1, + }; + + mockRunAgentSequence([ + makeResponse({ persona: movement.persona, content: 'done' }), + ]); + mockDetectMatchedRuleSequence([{ index: 0, method: 'phase1_tag' }]); + + engine = new PieceEngine(config, tmpDir, 'test task', { + projectCwd: tmpDir, + provider: 'claude', + globalProviderOptions: { + codex: { networkAccess: true }, + claude: { sandbox: { allowUnsandboxedCommands: false } }, + }, + projectProviderOptions: { + claude: { sandbox: { allowUnsandboxedCommands: true } }, + opencode: { networkAccess: true }, + }, + }); + + await engine.run(); + + const options = vi.mocked(runAgent).mock.calls[0]?.[2]; + expect(options?.providerOptions).toEqual({ + codex: { networkAccess: false }, + opencode: { networkAccess: true }, + claude: { + sandbox: { + allowUnsandboxedCommands: true, + excludedCommands: ['./gradlew'], + }, + }, + }); + }); + + it('should pass global provider_options when project and movement options are absent', async () => { + const movement = makeMovement('implement', { + rules: [makeRule('done', 'COMPLETE')], + }); + + const config: PieceConfig = { + name: 'provider-options-global-only', + movements: [movement], + initialMovement: 'implement', + maxMovements: 1, + }; + + mockRunAgentSequence([ + makeResponse({ persona: movement.persona, content: 'done' }), + ]); + mockDetectMatchedRuleSequence([{ index: 0, method: 'phase1_tag' }]); + + engine = new PieceEngine(config, tmpDir, 'test task', { + projectCwd: tmpDir, + provider: 'claude', + globalProviderOptions: { + codex: { networkAccess: true }, + }, + }); + + await engine.run(); + + const options = vi.mocked(runAgent).mock.calls[0]?.[2]; + expect(options?.providerOptions).toEqual({ + codex: { networkAccess: true }, + }); + }); +}); diff --git a/src/__tests__/globalConfig-defaults.test.ts b/src/__tests__/globalConfig-defaults.test.ts index d02f0ff..d34f896 100644 --- a/src/__tests__/globalConfig-defaults.test.ts +++ b/src/__tests__/globalConfig-defaults.test.ts @@ -39,7 +39,6 @@ describe('loadGlobalConfig', () => { const config = loadGlobalConfig(); expect(config.language).toBe('en'); - expect(config.defaultPiece).toBe('default'); expect(config.logLevel).toBe('info'); expect(config.provider).toBe('claude'); expect(config.model).toBeUndefined(); @@ -79,6 +78,23 @@ describe('loadGlobalConfig', () => { expect(config.logLevel).toBe('debug'); }); + it('should apply env override for nested provider_options key', () => { + const original = process.env.TAKT_PROVIDER_OPTIONS_CLAUDE_SANDBOX_ALLOW_UNSANDBOXED_COMMANDS; + try { + process.env.TAKT_PROVIDER_OPTIONS_CLAUDE_SANDBOX_ALLOW_UNSANDBOXED_COMMANDS = 'true'; + invalidateGlobalConfigCache(); + + const config = loadGlobalConfig(); + expect(config.providerOptions?.claude?.sandbox?.allowUnsandboxedCommands).toBe(true); + } finally { + if (original === undefined) { + delete process.env.TAKT_PROVIDER_OPTIONS_CLAUDE_SANDBOX_ALLOW_UNSANDBOXED_COMMANDS; + } else { + process.env.TAKT_PROVIDER_OPTIONS_CLAUDE_SANDBOX_ALLOW_UNSANDBOXED_COMMANDS = original; + } + } + }); + it('should load pipeline config from config.yaml', () => { const taktDir = join(testHomeDir, '.takt'); mkdirSync(taktDir, { recursive: true }); diff --git a/src/__tests__/it-config-provider-options.test.ts b/src/__tests__/it-config-provider-options.test.ts new file mode 100644 index 0000000..5659f53 --- /dev/null +++ b/src/__tests__/it-config-provider-options.test.ts @@ -0,0 +1,203 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { randomUUID } from 'node:crypto'; + +vi.mock('../agents/runner.js', () => ({ + runAgent: vi.fn(), +})); + +vi.mock('../agents/ai-judge.js', async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + callAiJudge: vi.fn().mockResolvedValue(-1), + }; +}); + +vi.mock('../core/piece/phase-runner.js', () => ({ + needsStatusJudgmentPhase: vi.fn().mockReturnValue(false), + runReportPhase: vi.fn().mockResolvedValue(undefined), + runStatusJudgmentPhase: vi.fn().mockResolvedValue({ tag: '', ruleIndex: 0, method: 'auto_select' }), +})); + +vi.mock('../shared/utils/index.js', async (importOriginal) => ({ + ...(await importOriginal>()), + generateReportDir: vi.fn().mockReturnValue('test-report-dir'), +})); + +import { runAgent } from '../agents/runner.js'; +import { executeTask } from '../features/tasks/execute/taskExecution.js'; +import { invalidateGlobalConfigCache } from '../infra/config/index.js'; + +interface TestEnv { + projectDir: string; + globalDir: string; +} + +function createEnv(): TestEnv { + const root = join(tmpdir(), `takt-it-config-${randomUUID()}`); + const projectDir = join(root, 'project'); + const globalDir = join(root, 'global'); + + mkdirSync(projectDir, { recursive: true }); + mkdirSync(join(projectDir, '.takt', 'pieces', 'personas'), { recursive: true }); + mkdirSync(globalDir, { recursive: true }); + + writeFileSync( + join(projectDir, '.takt', 'pieces', 'config-it.yaml'), + [ + 'name: config-it', + 'description: config provider options integration test', + 'max_movements: 3', + 'initial_movement: plan', + 'movements:', + ' - name: plan', + ' persona: ./personas/planner.md', + ' instruction: "{task}"', + ' rules:', + ' - condition: done', + ' next: COMPLETE', + ].join('\n'), + 'utf-8', + ); + writeFileSync(join(projectDir, '.takt', 'pieces', 'personas', 'planner.md'), 'You are planner.', 'utf-8'); + + return { projectDir, globalDir }; +} + +function setGlobalConfig(globalDir: string, body: string): void { + writeFileSync(join(globalDir, 'config.yaml'), body, 'utf-8'); +} + +function setProjectConfig(projectDir: string, body: string): void { + writeFileSync(join(projectDir, '.takt', 'config.yaml'), body, 'utf-8'); +} + +function makeDoneResponse() { + return { + persona: 'planner', + status: 'done', + content: '[PLAN:1]\ndone', + timestamp: new Date(), + sessionId: 'session-it', + }; +} + +describe('IT: config provider_options reflection', () => { + let env: TestEnv; + let originalConfigDir: string | undefined; + let originalEnvCodex: string | undefined; + + beforeEach(() => { + vi.clearAllMocks(); + env = createEnv(); + originalConfigDir = process.env.TAKT_CONFIG_DIR; + originalEnvCodex = process.env.TAKT_PROVIDER_OPTIONS_CODEX_NETWORK_ACCESS; + + process.env.TAKT_CONFIG_DIR = env.globalDir; + delete process.env.TAKT_PROVIDER_OPTIONS_CODEX_NETWORK_ACCESS; + invalidateGlobalConfigCache(); + + vi.mocked(runAgent).mockResolvedValue(makeDoneResponse()); + }); + + afterEach(() => { + if (originalConfigDir === undefined) { + delete process.env.TAKT_CONFIG_DIR; + } else { + process.env.TAKT_CONFIG_DIR = originalConfigDir; + } + if (originalEnvCodex === undefined) { + delete process.env.TAKT_PROVIDER_OPTIONS_CODEX_NETWORK_ACCESS; + } else { + process.env.TAKT_PROVIDER_OPTIONS_CODEX_NETWORK_ACCESS = originalEnvCodex; + } + invalidateGlobalConfigCache(); + rmSync(join(env.projectDir, '..'), { recursive: true, force: true }); + }); + + it('global provider_options should be passed to runAgent', async () => { + setGlobalConfig( + env.globalDir, + [ + 'provider_options:', + ' codex:', + ' network_access: true', + ].join('\n'), + ); + + const ok = await executeTask({ + task: 'test task', + cwd: env.projectDir, + projectCwd: env.projectDir, + pieceIdentifier: 'config-it', + }); + + expect(ok).toBe(true); + const options = vi.mocked(runAgent).mock.calls[0]?.[2]; + expect(options?.providerOptions).toEqual({ + codex: { networkAccess: true }, + }); + }); + + it('project provider_options should override global provider_options', async () => { + setGlobalConfig( + env.globalDir, + [ + 'provider_options:', + ' codex:', + ' network_access: true', + ].join('\n'), + ); + setProjectConfig( + env.projectDir, + [ + 'provider_options:', + ' codex:', + ' network_access: false', + ].join('\n'), + ); + + const ok = await executeTask({ + task: 'test task', + cwd: env.projectDir, + projectCwd: env.projectDir, + pieceIdentifier: 'config-it', + }); + + expect(ok).toBe(true); + const options = vi.mocked(runAgent).mock.calls[0]?.[2]; + expect(options?.providerOptions).toEqual({ + codex: { networkAccess: false }, + }); + }); + + it('env provider_options should override yaml provider_options', async () => { + setGlobalConfig( + env.globalDir, + [ + 'provider_options:', + ' codex:', + ' network_access: true', + ].join('\n'), + ); + process.env.TAKT_PROVIDER_OPTIONS_CODEX_NETWORK_ACCESS = 'false'; + invalidateGlobalConfigCache(); + + const ok = await executeTask({ + task: 'test task', + cwd: env.projectDir, + projectCwd: env.projectDir, + pieceIdentifier: 'config-it', + }); + + expect(ok).toBe(true); + const options = vi.mocked(runAgent).mock.calls[0]?.[2]; + expect(options?.providerOptions).toEqual({ + codex: { networkAccess: false }, + }); + }); +}); + diff --git a/src/__tests__/it-run-config-provider-options.test.ts b/src/__tests__/it-run-config-provider-options.test.ts new file mode 100644 index 0000000..4572f9a --- /dev/null +++ b/src/__tests__/it-run-config-provider-options.test.ts @@ -0,0 +1,170 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { mkdirSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { randomUUID } from 'node:crypto'; + +vi.mock('../agents/runner.js', () => ({ + runAgent: vi.fn(), +})); + +vi.mock('../agents/ai-judge.js', async (importOriginal) => { + const original = await importOriginal(); + return { + ...original, + callAiJudge: vi.fn().mockResolvedValue(-1), + }; +}); + +vi.mock('../core/piece/phase-runner.js', () => ({ + needsStatusJudgmentPhase: vi.fn().mockReturnValue(false), + runReportPhase: vi.fn().mockResolvedValue(undefined), + runStatusJudgmentPhase: vi.fn().mockResolvedValue({ tag: '', ruleIndex: 0, method: 'auto_select' }), +})); + +vi.mock('../shared/utils/index.js', async (importOriginal) => ({ + ...(await importOriginal>()), + generateReportDir: vi.fn().mockReturnValue('test-report-dir'), + notifySuccess: vi.fn(), + notifyError: vi.fn(), + sendSlackNotification: vi.fn(), + getSlackWebhookUrl: vi.fn(() => undefined), +})); + +import { runAllTasks } from '../features/tasks/index.js'; +import { TaskRunner } from '../infra/task/index.js'; +import { runAgent } from '../agents/runner.js'; +import { invalidateGlobalConfigCache } from '../infra/config/index.js'; + +interface TestEnv { + root: string; + projectDir: string; + globalDir: string; +} + +function createEnv(): TestEnv { + const root = join(tmpdir(), `takt-it-run-config-${randomUUID()}`); + const projectDir = join(root, 'project'); + const globalDir = join(root, 'global'); + + mkdirSync(join(projectDir, '.takt', 'pieces', 'personas'), { recursive: true }); + mkdirSync(globalDir, { recursive: true }); + + writeFileSync( + join(projectDir, '.takt', 'pieces', 'run-config-it.yaml'), + [ + 'name: run-config-it', + 'description: run config provider options integration test', + 'max_movements: 3', + 'initial_movement: plan', + 'movements:', + ' - name: plan', + ' persona: ./personas/planner.md', + ' instruction: "{task}"', + ' rules:', + ' - condition: done', + ' next: COMPLETE', + ].join('\n'), + 'utf-8', + ); + writeFileSync(join(projectDir, '.takt', 'pieces', 'personas', 'planner.md'), 'You are planner.', 'utf-8'); + + return { root, projectDir, globalDir }; +} + +function setGlobalConfig(globalDir: string, body: string): void { + writeFileSync(join(globalDir, 'config.yaml'), body, 'utf-8'); +} + +function setProjectConfig(projectDir: string, body: string): void { + writeFileSync(join(projectDir, '.takt', 'config.yaml'), body, 'utf-8'); +} + +function mockDoneResponse() { + return { + persona: 'planner', + status: 'done', + content: '[PLAN:1]\ndone', + timestamp: new Date(), + sessionId: 'session-it', + }; +} + +describe('IT: runAllTasks provider_options reflection', () => { + let env: TestEnv; + let originalConfigDir: string | undefined; + let originalEnvCodex: string | undefined; + + beforeEach(() => { + vi.clearAllMocks(); + env = createEnv(); + originalConfigDir = process.env.TAKT_CONFIG_DIR; + originalEnvCodex = process.env.TAKT_PROVIDER_OPTIONS_CODEX_NETWORK_ACCESS; + process.env.TAKT_CONFIG_DIR = env.globalDir; + delete process.env.TAKT_PROVIDER_OPTIONS_CODEX_NETWORK_ACCESS; + invalidateGlobalConfigCache(); + + vi.mocked(runAgent).mockResolvedValue(mockDoneResponse()); + + const runner = new TaskRunner(env.projectDir); + runner.addTask('test task'); + }); + + afterEach(() => { + if (originalConfigDir === undefined) { + delete process.env.TAKT_CONFIG_DIR; + } else { + process.env.TAKT_CONFIG_DIR = originalConfigDir; + } + if (originalEnvCodex === undefined) { + delete process.env.TAKT_PROVIDER_OPTIONS_CODEX_NETWORK_ACCESS; + } else { + process.env.TAKT_PROVIDER_OPTIONS_CODEX_NETWORK_ACCESS = originalEnvCodex; + } + invalidateGlobalConfigCache(); + rmSync(env.root, { recursive: true, force: true }); + }); + + it('project provider_options should override global in runAllTasks flow', async () => { + setGlobalConfig(env.globalDir, [ + 'provider_options:', + ' codex:', + ' network_access: true', + ].join('\n')); + setProjectConfig(env.projectDir, [ + 'provider_options:', + ' codex:', + ' network_access: false', + ].join('\n')); + + await runAllTasks(env.projectDir, 'run-config-it'); + + const options = vi.mocked(runAgent).mock.calls[0]?.[2]; + expect(options?.providerOptions).toEqual({ + codex: { networkAccess: false }, + }); + }); + + it('env provider_options should override yaml in runAllTasks flow', async () => { + setGlobalConfig(env.globalDir, [ + 'provider_options:', + ' codex:', + ' network_access: false', + ].join('\n')); + setProjectConfig(env.projectDir, [ + 'provider_options:', + ' codex:', + ' network_access: false', + ].join('\n')); + process.env.TAKT_PROVIDER_OPTIONS_CODEX_NETWORK_ACCESS = 'true'; + invalidateGlobalConfigCache(); + + await runAllTasks(env.projectDir, 'run-config-it'); + + const options = vi.mocked(runAgent).mock.calls[0]?.[2]; + expect(options?.providerOptions).toEqual({ + codex: { networkAccess: true }, + }); + }); +}); + diff --git a/src/__tests__/models.test.ts b/src/__tests__/models.test.ts index 1c93dcd..e336ba7 100644 --- a/src/__tests__/models.test.ts +++ b/src/__tests__/models.test.ts @@ -495,7 +495,6 @@ describe('GlobalConfigSchema', () => { const config = {}; const result = GlobalConfigSchema.parse(config); - expect(result.default_piece).toBe('default'); expect(result.log_level).toBe('info'); expect(result.provider).toBe('claude'); expect(result.observability).toBeUndefined(); @@ -503,7 +502,6 @@ describe('GlobalConfigSchema', () => { it('should accept valid config', () => { const config = { - default_piece: 'custom', log_level: 'debug' as const, observability: { provider_events: false, diff --git a/src/__tests__/option-resolution-order.test.ts b/src/__tests__/option-resolution-order.test.ts index fc71099..ec5d80f 100644 --- a/src/__tests__/option-resolution-order.test.ts +++ b/src/__tests__/option-resolution-order.test.ts @@ -2,8 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; const { getProviderMock, - loadProjectConfigMock, - loadGlobalConfigMock, + loadConfigMock, loadCustomAgentsMock, loadAgentPromptMock, loadTemplateMock, @@ -15,8 +14,7 @@ const { return { getProviderMock: vi.fn(() => ({ setup: providerSetup })), - loadProjectConfigMock: vi.fn(), - loadGlobalConfigMock: vi.fn(), + loadConfigMock: vi.fn(), loadCustomAgentsMock: vi.fn(), loadAgentPromptMock: vi.fn(), loadTemplateMock: vi.fn(), @@ -30,8 +28,7 @@ vi.mock('../infra/providers/index.js', () => ({ })); vi.mock('../infra/config/index.js', () => ({ - loadProjectConfig: loadProjectConfigMock, - loadGlobalConfig: loadGlobalConfigMock, + loadConfig: loadConfigMock, loadCustomAgents: loadCustomAgentsMock, loadAgentPrompt: loadAgentPromptMock, })); @@ -47,8 +44,7 @@ describe('option resolution order', () => { vi.clearAllMocks(); providerCallMock.mockResolvedValue({ content: 'ok' }); - loadProjectConfigMock.mockReturnValue({}); - loadGlobalConfigMock.mockReturnValue({}); + loadConfigMock.mockReturnValue({ global: {}, project: {} }); loadCustomAgentsMock.mockReturnValue(new Map()); loadAgentPromptMock.mockReturnValue('prompt'); loadTemplateMock.mockReturnValue('template'); @@ -56,8 +52,10 @@ describe('option resolution order', () => { it('should resolve provider in order: CLI > Local > Piece(step) > Global', async () => { // Given - loadProjectConfigMock.mockReturnValue({ provider: 'opencode' }); - loadGlobalConfigMock.mockReturnValue({ provider: 'mock' }); + loadConfigMock.mockReturnValue({ + project: { provider: 'opencode' }, + global: { provider: 'mock' }, + }); // When: CLI provider が指定される await runAgent(undefined, 'task', { @@ -79,7 +77,10 @@ describe('option resolution order', () => { expect(getProviderMock).toHaveBeenLastCalledWith('opencode'); // When: Local なし(Piece が有効) - loadProjectConfigMock.mockReturnValue({}); + loadConfigMock.mockReturnValue({ + project: {}, + global: { provider: 'mock' }, + }); await runAgent(undefined, 'task', { cwd: '/repo', stepProvider: 'claude', @@ -97,8 +98,10 @@ describe('option resolution order', () => { it('should resolve model in order: CLI > Piece(step) > Global(matching provider)', async () => { // Given - loadProjectConfigMock.mockReturnValue({ provider: 'claude' }); - loadGlobalConfigMock.mockReturnValue({ provider: 'claude', model: 'global-model' }); + loadConfigMock.mockReturnValue({ + project: { provider: 'claude' }, + global: { provider: 'claude', model: 'global-model' }, + }); // When: CLI model あり await runAgent(undefined, 'task', { @@ -137,8 +140,10 @@ describe('option resolution order', () => { it('should ignore global model when global provider does not match resolved provider', async () => { // Given - loadProjectConfigMock.mockReturnValue({ provider: 'codex' }); - loadGlobalConfigMock.mockReturnValue({ provider: 'claude', model: 'global-model' }); + loadConfigMock.mockReturnValue({ + project: { provider: 'codex' }, + global: { provider: 'claude', model: 'global-model' }, + }); // When await runAgent(undefined, 'task', { cwd: '/repo' }); @@ -160,16 +165,15 @@ describe('option resolution order', () => { }, }; - loadProjectConfigMock.mockReturnValue({ - provider: 'claude', - provider_options: { - claude: { sandbox: { allow_unsandboxed_commands: true } }, + loadConfigMock.mockReturnValue({ + project: { + provider: 'claude', }, - }); - loadGlobalConfigMock.mockReturnValue({ - provider: 'claude', - providerOptions: { - claude: { sandbox: { allowUnsandboxedCommands: true } }, + global: { + provider: 'claude', + providerOptions: { + claude: { sandbox: { allowUnsandboxedCommands: true } }, + }, }, }); diff --git a/src/__tests__/options-builder.test.ts b/src/__tests__/options-builder.test.ts index 0a6d1f7..bda6033 100644 --- a/src/__tests__/options-builder.test.ts +++ b/src/__tests__/options-builder.test.ts @@ -69,6 +69,56 @@ describe('OptionsBuilder.buildBaseOptions', () => { const options = builder.buildBaseOptions(step); expect(options.permissionMode).toBe('edit'); }); + + it('merges provider options with precedence: global < project < movement', () => { + const step = createMovement({ + providerOptions: { + codex: { networkAccess: false }, + claude: { sandbox: { excludedCommands: ['./gradlew'] } }, + }, + }); + const builder = createBuilder(step, { + globalProviderOptions: { + codex: { networkAccess: true }, + claude: { sandbox: { allowUnsandboxedCommands: false } }, + }, + projectProviderOptions: { + claude: { sandbox: { allowUnsandboxedCommands: true } }, + opencode: { networkAccess: true }, + }, + }); + + const options = builder.buildBaseOptions(step); + + expect(options.providerOptions).toEqual({ + codex: { networkAccess: false }, + opencode: { networkAccess: true }, + claude: { + sandbox: { + allowUnsandboxedCommands: true, + excludedCommands: ['./gradlew'], + }, + }, + }); + }); + + it('falls back to global/project provider options when movement has none', () => { + const step = createMovement(); + const builder = createBuilder(step, { + globalProviderOptions: { + codex: { networkAccess: true }, + }, + projectProviderOptions: { + codex: { networkAccess: false }, + }, + }); + + const options = builder.buildBaseOptions(step); + + expect(options.providerOptions).toEqual({ + codex: { networkAccess: false }, + }); + }); }); describe('OptionsBuilder.buildResumeOptions', () => { diff --git a/src/__tests__/runAllTasks-concurrency.test.ts b/src/__tests__/runAllTasks-concurrency.test.ts index 86c7d60..52943a7 100644 --- a/src/__tests__/runAllTasks-concurrency.test.ts +++ b/src/__tests__/runAllTasks-concurrency.test.ts @@ -5,25 +5,33 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import type { TaskInfo } from '../infra/task/index.js'; -// Mock dependencies before importing the module under test -vi.mock('../infra/config/index.js', () => ({ - loadPieceByIdentifier: vi.fn(), - isPiecePath: vi.fn(() => false), - loadGlobalConfig: vi.fn(() => ({ +const { mockLoadConfigRaw } = vi.hoisted(() => ({ + mockLoadConfigRaw: vi.fn(() => ({ language: 'en', defaultPiece: 'default', logLevel: 'info', concurrency: 1, taskPollIntervalMs: 500, })), - loadProjectConfig: vi.fn(() => ({ - piece: 'default', - permissionMode: 'default', - })), })); -import { loadGlobalConfig } from '../infra/config/index.js'; -const mockLoadGlobalConfig = vi.mocked(loadGlobalConfig); +// Mock dependencies before importing the module under test +vi.mock('../infra/config/index.js', () => ({ + loadPieceByIdentifier: vi.fn(), + isPiecePath: vi.fn(() => false), + loadConfig: (...args: unknown[]) => { + const raw = mockLoadConfigRaw(...args) as Record; + if ('global' in raw && 'project' in raw) { + return raw; + } + return { + global: raw, + project: { piece: 'default' }, + }; + }, +})); + +const mockLoadConfig = mockLoadConfigRaw; const { mockClaimNextTasks, @@ -167,7 +175,7 @@ beforeEach(() => { describe('runAllTasks concurrency', () => { describe('sequential execution (concurrency=1)', () => { beforeEach(() => { - mockLoadGlobalConfig.mockReturnValue({ + mockLoadConfig.mockReturnValue({ language: 'en', defaultPiece: 'default', logLevel: 'info', @@ -210,7 +218,7 @@ describe('runAllTasks concurrency', () => { describe('parallel execution (concurrency>1)', () => { beforeEach(() => { - mockLoadGlobalConfig.mockReturnValue({ + mockLoadConfig.mockReturnValue({ language: 'en', defaultPiece: 'default', logLevel: 'info', @@ -288,7 +296,7 @@ describe('runAllTasks concurrency', () => { describe('default concurrency', () => { it('should default to sequential when concurrency is not set', async () => { // Given: Config without explicit concurrency (defaults to 1) - mockLoadGlobalConfig.mockReturnValue({ + mockLoadConfig.mockReturnValue({ language: 'en', defaultPiece: 'default', logLevel: 'info', @@ -324,7 +332,7 @@ describe('runAllTasks concurrency', () => { }; beforeEach(() => { - mockLoadGlobalConfig.mockReturnValue({ + mockLoadConfig.mockReturnValue({ language: 'en', defaultPiece: 'default', logLevel: 'info', @@ -371,7 +379,7 @@ describe('runAllTasks concurrency', () => { it('should fill slots immediately when a task completes (no batch waiting)', async () => { // Given: 3 tasks, concurrency=2, task1 finishes quickly, task2 takes longer - mockLoadGlobalConfig.mockReturnValue({ + mockLoadConfig.mockReturnValue({ language: 'en', defaultPiece: 'default', logLevel: 'info', @@ -413,7 +421,7 @@ describe('runAllTasks concurrency', () => { it('should count partial failures correctly', async () => { // Given: 3 tasks, 1 fails, 2 succeed - mockLoadGlobalConfig.mockReturnValue({ + mockLoadConfig.mockReturnValue({ language: 'en', defaultPiece: 'default', logLevel: 'info', @@ -495,7 +503,7 @@ describe('runAllTasks concurrency', () => { it('should pass abortSignal but not taskPrefix in sequential mode', async () => { // Given: Sequential mode - mockLoadGlobalConfig.mockReturnValue({ + mockLoadConfig.mockReturnValue({ language: 'en', defaultPiece: 'default', logLevel: 'info', @@ -525,7 +533,7 @@ describe('runAllTasks concurrency', () => { }); it('should only notify once at run completion when multiple tasks succeed', async () => { - mockLoadGlobalConfig.mockReturnValue({ + mockLoadConfig.mockReturnValue({ language: 'en', defaultPiece: 'default', logLevel: 'info', @@ -550,7 +558,7 @@ describe('runAllTasks concurrency', () => { }); it('should not notify run completion when runComplete is explicitly false', async () => { - mockLoadGlobalConfig.mockReturnValue({ + mockLoadConfig.mockReturnValue({ language: 'en', defaultPiece: 'default', logLevel: 'info', @@ -572,7 +580,7 @@ describe('runAllTasks concurrency', () => { }); it('should notify run completion by default when notification_sound_events is not set', async () => { - mockLoadGlobalConfig.mockReturnValue({ + mockLoadConfig.mockReturnValue({ language: 'en', defaultPiece: 'default', logLevel: 'info', @@ -594,7 +602,7 @@ describe('runAllTasks concurrency', () => { }); it('should notify run abort by default when notification_sound_events is not set', async () => { - mockLoadGlobalConfig.mockReturnValue({ + mockLoadConfig.mockReturnValue({ language: 'en', defaultPiece: 'default', logLevel: 'info', @@ -617,7 +625,7 @@ describe('runAllTasks concurrency', () => { }); it('should not notify run abort when runAbort is explicitly false', async () => { - mockLoadGlobalConfig.mockReturnValue({ + mockLoadConfig.mockReturnValue({ language: 'en', defaultPiece: 'default', logLevel: 'info', @@ -640,7 +648,7 @@ describe('runAllTasks concurrency', () => { }); it('should notify run abort and rethrow when worker pool throws', async () => { - mockLoadGlobalConfig.mockReturnValue({ + mockLoadConfig.mockReturnValue({ language: 'en', defaultPiece: 'default', logLevel: 'info', @@ -675,7 +683,7 @@ describe('runAllTasks concurrency', () => { }; beforeEach(() => { - mockLoadGlobalConfig.mockReturnValue({ + mockLoadConfig.mockReturnValue({ language: 'en', defaultPiece: 'default', logLevel: 'info', diff --git a/src/__tests__/selectAndExecute-autoPr.test.ts b/src/__tests__/selectAndExecute-autoPr.test.ts index 2224f78..470dff2 100644 --- a/src/__tests__/selectAndExecute-autoPr.test.ts +++ b/src/__tests__/selectAndExecute-autoPr.test.ts @@ -32,7 +32,7 @@ vi.mock('../infra/config/index.js', () => ({ listPieces: vi.fn(() => ['default']), listPieceEntries: vi.fn(() => []), isPiecePath: vi.fn(() => false), - loadGlobalConfig: vi.fn(() => ({})), + loadConfig: vi.fn(() => ({ global: {}, project: {} })), })); vi.mock('../infra/task/index.js', () => ({ diff --git a/src/__tests__/taskExecution.test.ts b/src/__tests__/taskExecution.test.ts index 27c6ada..dbe547d 100644 --- a/src/__tests__/taskExecution.test.ts +++ b/src/__tests__/taskExecution.test.ts @@ -5,7 +5,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import type { TaskInfo } from '../infra/task/index.js'; -const { mockResolveTaskExecution, mockExecutePiece, mockLoadPieceByIdentifier, mockLoadGlobalConfig, mockLoadProjectConfig, mockBuildTaskResult, mockPersistTaskResult, mockPostExecutionFlow } = +const { mockResolveTaskExecution, mockExecutePiece, mockLoadPieceByIdentifier, mockLoadGlobalConfig, mockLoadProjectConfig, mockBuildTaskResult, mockPersistTaskResult, mockPersistTaskError, mockPostExecutionFlow } = vi.hoisted(() => ({ mockResolveTaskExecution: vi.fn(), mockExecutePiece: vi.fn(), @@ -14,6 +14,7 @@ const { mockResolveTaskExecution, mockExecutePiece, mockLoadPieceByIdentifier, m mockLoadProjectConfig: vi.fn(), mockBuildTaskResult: vi.fn(), mockPersistTaskResult: vi.fn(), + mockPersistTaskError: vi.fn(), mockPostExecutionFlow: vi.fn(), })); @@ -28,6 +29,7 @@ vi.mock('../features/tasks/execute/pieceExecution.js', () => ({ vi.mock('../features/tasks/execute/taskResultHandler.js', () => ({ buildTaskResult: (...args: unknown[]) => mockBuildTaskResult(...args), persistTaskResult: (...args: unknown[]) => mockPersistTaskResult(...args), + persistTaskError: (...args: unknown[]) => mockPersistTaskError(...args), })); vi.mock('../features/tasks/execute/postExecution.js', () => ({ @@ -37,8 +39,10 @@ vi.mock('../features/tasks/execute/postExecution.js', () => ({ vi.mock('../infra/config/index.js', () => ({ loadPieceByIdentifier: (...args: unknown[]) => mockLoadPieceByIdentifier(...args), isPiecePath: () => false, - loadGlobalConfig: () => mockLoadGlobalConfig(), - loadProjectConfig: () => mockLoadProjectConfig(), + loadConfig: () => ({ + global: mockLoadGlobalConfig(), + project: mockLoadProjectConfig(), + }), })); vi.mock('../shared/ui/index.js', () => ({ @@ -88,10 +92,16 @@ describe('executeAndCompleteTask', () => { provider: 'claude', personaProviders: {}, providerProfiles: {}, + providerOptions: { + claude: { sandbox: { allowUnsandboxedCommands: true } }, + }, }); mockLoadProjectConfig.mockReturnValue({ provider: 'claude', providerProfiles: {}, + providerOptions: { + opencode: { networkAccess: true }, + }, }); mockBuildTaskResult.mockReturnValue({ success: true }); mockResolveTaskExecution.mockResolvedValue({ @@ -130,8 +140,16 @@ describe('executeAndCompleteTask', () => { const pieceExecutionOptions = mockExecutePiece.mock.calls[0]?.[3] as { taskDisplayLabel?: string; taskPrefix?: string; + globalProviderOptions?: unknown; + projectProviderOptions?: unknown; }; expect(pieceExecutionOptions?.taskDisplayLabel).toBe(taskDisplayLabel); expect(pieceExecutionOptions?.taskPrefix).toBe(taskDisplayLabel); + expect(pieceExecutionOptions?.globalProviderOptions).toEqual({ + claude: { sandbox: { allowUnsandboxedCommands: true } }, + }); + expect(pieceExecutionOptions?.projectProviderOptions).toEqual({ + opencode: { networkAccess: true }, + }); }); }); diff --git a/src/agents/runner.ts b/src/agents/runner.ts index 560742e..bb853c5 100644 --- a/src/agents/runner.ts +++ b/src/agents/runner.ts @@ -4,7 +4,7 @@ import { existsSync, readFileSync } from 'node:fs'; import { basename, dirname } from 'node:path'; -import { loadCustomAgents, loadAgentPrompt, loadGlobalConfig, loadProjectConfig } from '../infra/config/index.js'; +import { loadCustomAgents, loadAgentPrompt, loadConfig } 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,12 +29,13 @@ export class AgentRunner { agentConfig?: CustomAgentConfig, ): ProviderType { if (options?.provider) return options.provider; - const projectConfig = loadProjectConfig(cwd); + const config = loadConfig(cwd); + const projectConfig = config.project; if (projectConfig.provider) return projectConfig.provider; if (options?.stepProvider) return options.stepProvider; if (agentConfig?.provider) return agentConfig.provider; try { - const globalConfig = loadGlobalConfig(); + const globalConfig = config.global; if (globalConfig.provider) return globalConfig.provider; } catch (error) { log.debug('Global config not available for provider resolution', { error }); @@ -55,8 +56,9 @@ export class AgentRunner { if (options?.model) return options.model; if (options?.stepModel) return options.stepModel; if (agentConfig?.model) return agentConfig.model; + if (!options?.cwd) return undefined; try { - const globalConfig = loadGlobalConfig(); + const globalConfig = loadConfig(options.cwd).global; if (globalConfig.model) { const globalProvider = globalConfig.provider ?? 'claude'; if (globalProvider === resolvedProvider) return globalConfig.model; diff --git a/src/app/cli/commands.ts b/src/app/cli/commands.ts index 4c12eb1..8f336b3 100644 --- a/src/app/cli/commands.ts +++ b/src/app/cli/commands.ts @@ -1,13 +1,13 @@ /** * CLI subcommand definitions * - * Registers all named subcommands (run, watch, add, list, switch, clear, eject, config, prompt, catalog). + * Registers all named subcommands (run, watch, add, list, switch, clear, eject, prompt, catalog). */ import { clearPersonaSessions, getCurrentPiece } from '../../infra/config/index.js'; import { success } from '../../shared/ui/index.js'; import { runAllTasks, addTask, watchTasks, listTasks } from '../../features/tasks/index.js'; -import { switchPiece, switchConfig, ejectBuiltin, ejectFacet, parseFacetType, VALID_FACET_TYPES, resetCategoriesToDefault, deploySkill } from '../../features/config/index.js'; +import { switchPiece, ejectBuiltin, ejectFacet, parseFacetType, VALID_FACET_TYPES, resetCategoriesToDefault, deploySkill } from '../../features/config/index.js'; import { previewPrompts } from '../../features/prompt/index.js'; import { showCatalog } from '../../features/catalog/index.js'; import { program, resolvedCwd } from './program.js'; @@ -96,14 +96,6 @@ program } }); -program - .command('config') - .description('Configure settings (permission mode)') - .argument('[key]', 'Configuration key') - .action(async (key?: string) => { - await switchConfig(resolvedCwd, key); - }); - const reset = program .command('reset') .description('Reset settings to defaults'); diff --git a/src/app/cli/program.ts b/src/app/cli/program.ts index 728f8ca..7814cd1 100644 --- a/src/app/cli/program.ts +++ b/src/app/cli/program.ts @@ -11,7 +11,7 @@ import { resolve } from 'node:path'; import { initGlobalDirs, initProjectDirs, - loadGlobalConfig, + loadConfig, isVerboseMode, } from '../../infra/config/index.js'; import { setQuietMode } from '../../shared/context.js'; @@ -69,7 +69,7 @@ export async function runPreActionHook(): Promise { const verbose = isVerboseMode(resolvedCwd); initDebugLogger(verbose ? { enabled: true } : undefined, resolvedCwd); - const config = loadGlobalConfig(); + const { global: config } = loadConfig(resolvedCwd); if (verbose) { setVerboseConsole(true); diff --git a/src/app/cli/routing.ts b/src/app/cli/routing.ts index 02870e3..5d253a1 100644 --- a/src/app/cli/routing.ts +++ b/src/app/cli/routing.ts @@ -23,7 +23,7 @@ import { dispatchConversationAction, type InteractiveModeResult, } from '../../features/interactive/index.js'; -import { getPieceDescription, loadGlobalConfig } from '../../infra/config/index.js'; +import { getPieceDescription, loadConfig } from '../../infra/config/index.js'; import { DEFAULT_PIECE_NAME } from '../../shared/constants.js'; import { program, resolvedCwd, pipelineMode } from './program.js'; import { resolveAgentOverrides, parseCreateWorktreeOption, isDirectTask } from './helpers.js'; @@ -137,7 +137,7 @@ export async function executeDefaultAction(task?: string): Promise { } // All paths below go through interactive mode - const globalConfig = loadGlobalConfig(); + const { global: globalConfig } = loadConfig(resolvedCwd); const lang = resolveLanguage(globalConfig.language); const pieceId = await determinePiece(resolvedCwd, selectOptions.piece); diff --git a/src/core/models/global-config.ts b/src/core/models/global-config.ts index 9214dfe..ed3fba2 100644 --- a/src/core/models/global-config.ts +++ b/src/core/models/global-config.ts @@ -53,7 +53,6 @@ export interface NotificationSoundEventsConfig { /** Global configuration for takt */ export interface GlobalConfig { language: Language; - defaultPiece: string; logLevel: 'debug' | 'info' | 'warn' | 'error'; provider?: 'claude' | 'codex' | 'opencode' | 'mock'; model?: string; @@ -100,6 +99,8 @@ export interface GlobalConfig { notificationSoundEvents?: NotificationSoundEventsConfig; /** Number of movement previews to inject into interactive mode (0 to disable, max 10) */ interactivePreviewMovements?: number; + /** Verbose output mode */ + verbose?: boolean; /** Number of tasks to run concurrently in takt run (default: 1 = sequential) */ concurrency: number; /** Polling interval in ms for picking up new tasks during takt run (default: 500, range: 100-5000) */ diff --git a/src/core/models/schemas.ts b/src/core/models/schemas.ts index c36ace7..05e43d0 100644 --- a/src/core/models/schemas.ts +++ b/src/core/models/schemas.ts @@ -405,7 +405,6 @@ export const PieceCategoryConfigSchema = z.record(z.string(), PieceCategoryConfi /** Global config schema */ export const GlobalConfigSchema = z.object({ language: LanguageSchema.optional().default(DEFAULT_LANGUAGE), - default_piece: z.string().optional().default('default'), log_level: z.enum(['debug', 'info', 'warn', 'error']).optional().default('info'), provider: z.enum(['claude', 'codex', 'opencode', 'mock']).optional().default('claude'), model: z.string().optional(), @@ -458,6 +457,8 @@ export const GlobalConfigSchema = z.object({ }).optional(), /** Number of movement previews to inject into interactive mode (0 to disable, max 10) */ interactive_preview_movements: z.number().int().min(0).max(10).optional().default(3), + /** Verbose output mode */ + verbose: z.boolean().optional(), /** Number of tasks to run concurrently in takt run (default: 1 = sequential, max: 10) */ concurrency: z.number().int().min(1).max(10).optional().default(1), /** Polling interval in ms for picking up new tasks during takt run (default: 500, range: 100-5000) */ diff --git a/src/core/piece/engine/OptionsBuilder.ts b/src/core/piece/engine/OptionsBuilder.ts index 259a572..12f5511 100644 --- a/src/core/piece/engine/OptionsBuilder.ts +++ b/src/core/piece/engine/OptionsBuilder.ts @@ -1,5 +1,6 @@ import { join } from 'node:path'; import type { PieceMovement, PieceState, Language } from '../../models/types.js'; +import type { MovementProviderOptions } from '../../models/piece-types.js'; import type { RunAgentOptions } from '../../../agents/runner.js'; import type { PhaseRunnerContext } from '../phase-runner.js'; import type { PieceEngineOptions, PhaseName } from '../types.js'; @@ -7,6 +8,27 @@ import { buildSessionKey } from '../session-key.js'; import { resolveMovementProviderModel } from '../provider-resolution.js'; import { DEFAULT_PROVIDER_PERMISSION_PROFILES, resolveMovementPermissionMode } from '../permission-profile-resolution.js'; +function mergeProviderOptions( + ...layers: (MovementProviderOptions | undefined)[] +): MovementProviderOptions | undefined { + const result: MovementProviderOptions = {}; + for (const layer of layers) { + if (!layer) continue; + if (layer.codex) { + result.codex = { ...result.codex, ...layer.codex }; + } + if (layer.opencode) { + result.opencode = { ...result.opencode, ...layer.opencode }; + } + if (layer.claude?.sandbox) { + result.claude = { + sandbox: { ...result.claude?.sandbox, ...layer.claude.sandbox }, + }; + } + } + return Object.keys(result).length > 0 ? result : undefined; +} + export class OptionsBuilder { constructor( private readonly engineOptions: PieceEngineOptions, @@ -54,7 +76,11 @@ export class OptionsBuilder { projectProviderProfiles: this.engineOptions.projectProviderProfiles, globalProviderProfiles: this.engineOptions.globalProviderProfiles ?? DEFAULT_PROVIDER_PERMISSION_PROFILES, }), - providerOptions: step.providerOptions, + providerOptions: mergeProviderOptions( + this.engineOptions.globalProviderOptions, + this.engineOptions.projectProviderOptions, + step.providerOptions, + ), language: this.getLanguage(), onStream: this.engineOptions.onStream, onPermissionRequest: this.engineOptions.onPermissionRequest, diff --git a/src/core/piece/types.ts b/src/core/piece/types.ts index 8211673..ef8c014 100644 --- a/src/core/piece/types.ts +++ b/src/core/piece/types.ts @@ -8,6 +8,7 @@ import type { PermissionResult, PermissionUpdate } from '@anthropic-ai/claude-agent-sdk'; import type { PieceMovement, AgentResponse, PieceState, Language, LoopMonitorConfig } from '../models/types.js'; import type { ProviderPermissionProfiles } from '../models/provider-profiles.js'; +import type { MovementProviderOptions } from '../models/piece-types.js'; export type ProviderType = 'claude' | 'codex' | 'opencode' | 'mock'; @@ -171,7 +172,7 @@ export interface PieceEngineOptions { onAskUserQuestion?: AskUserQuestionHandler; /** Callback when iteration limit is reached - returns additional iterations or null to stop */ onIterationLimit?: IterationLimitCallback; - /** Bypass all permission checks (sacrifice-my-pc mode) */ + /** Bypass all permission checks */ bypassPermissions?: boolean; /** Project root directory (where .takt/ lives). */ projectCwd: string; @@ -183,6 +184,10 @@ export interface PieceEngineOptions { /** 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; /** Per-persona provider overrides (e.g., { coder: 'codex' }) */ personaProviders?: Record; /** Project-level provider permission profiles */ diff --git a/src/features/config/index.ts b/src/features/config/index.ts index 2847c03..c42df30 100644 --- a/src/features/config/index.ts +++ b/src/features/config/index.ts @@ -3,7 +3,6 @@ */ export { switchPiece } from './switchPiece.js'; -export { switchConfig, getCurrentPermissionMode, setPermissionMode, type PermissionMode } from './switchConfig.js'; export { ejectBuiltin, ejectFacet, parseFacetType, VALID_FACET_TYPES } from './ejectBuiltin.js'; export { resetCategoriesToDefault } from './resetCategories.js'; export { deploySkill } from './deploySkill.js'; diff --git a/src/features/config/switchConfig.ts b/src/features/config/switchConfig.ts deleted file mode 100644 index 048e8a1..0000000 --- a/src/features/config/switchConfig.ts +++ /dev/null @@ -1,134 +0,0 @@ -/** - * Config switching command (like piece switching) - * - * Permission mode selection that works from CLI. - * Uses selectOption for prompt selection, same pattern as switchPiece. - */ - -import chalk from 'chalk'; -import { info, success } from '../../shared/ui/index.js'; -import { selectOption } from '../../shared/prompt/index.js'; -import { - loadProjectConfig, - updateProjectConfig, -} from '../../infra/config/index.js'; -import type { PermissionMode } from '../../infra/config/index.js'; - -// Re-export for convenience -export type { PermissionMode } from '../../infra/config/index.js'; - -/** - * Get permission mode options for selection - */ -/** Common permission mode option definitions */ -export const PERMISSION_MODE_OPTIONS: { - key: PermissionMode; - label: string; - description: string; - details: string[]; - icon: string; -}[] = [ - { - key: 'default', - label: 'デフォルト (default)', - description: 'Agent SDK標準モード(ファイル編集自動承認、最小限の確認)', - details: [ - 'Claude Agent SDKの標準設定(acceptEdits)を使用', - 'ファイル編集は自動承認され、確認プロンプトなしで実行', - 'Bash等の危険な操作は権限確認が表示される', - '通常の開発作業に推奨', - ], - icon: '📋', - }, - { - key: 'sacrifice-my-pc', - label: 'SACRIFICE-MY-PC', - description: '全ての権限リクエストが自動承認されます', - details: [ - '⚠️ 警告: 全ての操作が確認なしで実行されます', - 'Bash, ファイル削除, システム操作も自動承認', - 'ブロック状態(判断待ち)も自動スキップ', - '完全自動化が必要な場合のみ使用してください', - ], - icon: '💀', - }, -]; - -function getPermissionModeOptions(currentMode: PermissionMode): { - label: string; - value: PermissionMode; - description: string; - details: string[]; -}[] { - return PERMISSION_MODE_OPTIONS.map((opt) => ({ - label: currentMode === opt.key - ? (opt.key === 'sacrifice-my-pc' ? chalk.red : chalk.blue)(`${opt.icon} ${opt.label}`) + ' (current)' - : (opt.key === 'sacrifice-my-pc' ? chalk.red : chalk.blue)(`${opt.icon} ${opt.label}`), - value: opt.key, - description: opt.description, - details: opt.details, - })); -} - -/** - * Get current permission mode from project config - */ -export function getCurrentPermissionMode(cwd: string): PermissionMode { - const config = loadProjectConfig(cwd); - if (config.permissionMode) { - return config.permissionMode as PermissionMode; - } - return 'default'; -} - -/** - * Set permission mode in project config - */ -export function setPermissionMode(cwd: string, mode: PermissionMode): void { - updateProjectConfig(cwd, 'permissionMode', mode); -} - -/** - * Switch permission mode (like switchPiece) - * @returns true if switch was successful - */ -export async function switchConfig(cwd: string, modeName?: string): Promise { - const currentMode = getCurrentPermissionMode(cwd); - - // No mode specified - show selection prompt - if (!modeName) { - info(`Current mode: ${currentMode}`); - - const options = getPermissionModeOptions(currentMode); - const selected = await selectOption('Select permission mode:', options); - - if (!selected) { - info('Cancelled'); - return false; - } - - modeName = selected; - } - - // Validate mode name - if (modeName !== 'default' && modeName !== 'sacrifice-my-pc') { - info(`Invalid mode: ${modeName}`); - info('Available modes: default, sacrifice-my-pc'); - return false; - } - - const finalMode: PermissionMode = modeName as PermissionMode; - - // Save to project config - setPermissionMode(cwd, finalMode); - - if (finalMode === 'sacrifice-my-pc') { - success('Switched to: sacrifice-my-pc 💀'); - info('All permission requests will be auto-approved.'); - } else { - success('Switched to: default 📋'); - info('Using Agent SDK default mode (acceptEdits - minimal permission prompts).'); - } - - return true; -} diff --git a/src/features/interactive/conversationLoop.ts b/src/features/interactive/conversationLoop.ts index fb988ef..5baf581 100644 --- a/src/features/interactive/conversationLoop.ts +++ b/src/features/interactive/conversationLoop.ts @@ -10,7 +10,7 @@ import chalk from 'chalk'; import { - loadGlobalConfig, + loadConfig, 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 globalConfig = loadGlobalConfig(); + const { global: globalConfig } = loadConfig(cwd); const lang = resolveLanguage(globalConfig.language); if (!globalConfig.provider) { throw new Error('Provider is not configured.'); diff --git a/src/features/interactive/retryMode.ts b/src/features/interactive/retryMode.ts index 4b2bbbe..a3a66da 100644 --- a/src/features/interactive/retryMode.ts +++ b/src/features/interactive/retryMode.ts @@ -22,7 +22,7 @@ import { import { resolveLanguage } from './interactive.js'; import { loadTemplate } from '../../shared/prompts/index.js'; import { getLabelObject } from '../../shared/i18n/index.js'; -import { loadGlobalConfig } from '../../infra/config/index.js'; +import { loadConfig } from '../../infra/config/index.js'; import type { InstructModeResult, InstructUIText } from '../tasks/list/instructMode.js'; /** Failure information for a retry task */ @@ -116,7 +116,7 @@ export async function runRetryMode( cwd: string, retryContext: RetryContext, ): Promise { - const globalConfig = loadGlobalConfig(); + const { global: globalConfig } = loadConfig(cwd); const lang = resolveLanguage(globalConfig.language); if (!globalConfig.provider) { diff --git a/src/features/pipeline/execute.ts b/src/features/pipeline/execute.ts index f885872..0542a22 100644 --- a/src/features/pipeline/execute.ts +++ b/src/features/pipeline/execute.ts @@ -21,7 +21,7 @@ import { } from '../../infra/github/index.js'; import { stageAndCommit, getCurrentBranch } from '../../infra/task/index.js'; import { executeTask, type TaskExecutionOptions, type PipelineExecutionOptions } from '../tasks/index.js'; -import { loadGlobalConfig } from '../../infra/config/index.js'; +import { loadConfig } from '../../infra/config/index.js'; import { info, error, success, status, blankLine } from '../../shared/ui/index.js'; import { createLogger, getErrorMessage } from '../../shared/utils/index.js'; import type { PipelineConfig } from '../../core/models/index.js'; @@ -106,7 +106,7 @@ function buildPipelinePrBody( */ export async function executePipeline(options: PipelineExecutionOptions): Promise { const { cwd, piece, autoPr, skipGit } = options; - const globalConfig = loadGlobalConfig(); + const { global: globalConfig } = loadConfig(cwd); const pipelineConfig = globalConfig.pipeline; let issue: GitHubIssue | undefined; let task: string; diff --git a/src/features/prompt/preview.ts b/src/features/prompt/preview.ts index 27d5bc8..5264262 100644 --- a/src/features/prompt/preview.ts +++ b/src/features/prompt/preview.ts @@ -5,7 +5,7 @@ * Useful for debugging and understanding what prompts agents will receive. */ -import { loadPieceByIdentifier, getCurrentPiece, loadGlobalConfig } from '../../infra/config/index.js'; +import { loadPieceByIdentifier, getCurrentPiece, loadConfig } 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,7 +29,7 @@ export async function previewPrompts(cwd: string, pieceIdentifier?: string): Pro return; } - const globalConfig = loadGlobalConfig(); + const { global: globalConfig } = loadConfig(cwd); const language: Language = globalConfig.language ?? 'en'; header(`Prompt Preview: ${config.name}`); diff --git a/src/features/tasks/execute/pieceExecution.ts b/src/features/tasks/execute/pieceExecution.ts index dbc05ac..584e4d4 100644 --- a/src/features/tasks/execute/pieceExecution.ts +++ b/src/features/tasks/execute/pieceExecution.ts @@ -17,7 +17,7 @@ import { updatePersonaSession, loadWorktreeSessions, updateWorktreeSession, - loadGlobalConfig, + loadConfig, saveSessionState, type SessionState, } from '../../../infra/config/index.js'; @@ -317,7 +317,7 @@ export async function executePiece( // Load saved agent sessions only on retry; normal runs start with empty sessions const isWorktree = cwd !== projectCwd; - const globalConfig = loadGlobalConfig(); + const { global: globalConfig } = loadConfig(projectCwd); const shouldNotify = globalConfig.notificationSound !== false; const notificationSoundEvents = globalConfig.notificationSoundEvents; const shouldNotifyIterationLimit = shouldNotify && notificationSoundEvents?.iterationLimit !== false; @@ -446,6 +446,8 @@ export async function executePiece( projectProvider: options.projectProvider, globalProvider: options.globalProvider, model: options.model, + projectProviderOptions: options.projectProviderOptions, + globalProviderOptions: options.globalProviderOptions, personaProviders: options.personaProviders, projectProviderProfiles: options.projectProviderProfiles, globalProviderProfiles: options.globalProviderProfiles, diff --git a/src/features/tasks/execute/postExecution.ts b/src/features/tasks/execute/postExecution.ts index 2bb5bf9..857d459 100644 --- a/src/features/tasks/execute/postExecution.ts +++ b/src/features/tasks/execute/postExecution.ts @@ -5,7 +5,7 @@ * instructBranch (instruct mode from takt list). */ -import { loadGlobalConfig } from '../../../infra/config/index.js'; +import { loadConfig } 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'; @@ -18,12 +18,12 @@ const log = createLogger('postExecution'); /** * Resolve auto-PR setting with priority: CLI option > config > prompt. */ -export async function resolveAutoPr(optionAutoPr: boolean | undefined): Promise { +export async function resolveAutoPr(optionAutoPr: boolean | undefined, cwd: string): Promise { if (typeof optionAutoPr === 'boolean') { return optionAutoPr; } - const globalConfig = loadGlobalConfig(); + const { global: globalConfig } = loadConfig(cwd); if (typeof globalConfig.autoPr === 'boolean') { return globalConfig.autoPr; } diff --git a/src/features/tasks/execute/resolveTask.ts b/src/features/tasks/execute/resolveTask.ts index f4e5c3b..e7d0c2e 100644 --- a/src/features/tasks/execute/resolveTask.ts +++ b/src/features/tasks/execute/resolveTask.ts @@ -4,7 +4,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; -import { loadGlobalConfig } from '../../../infra/config/index.js'; +import { loadConfig } 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,7 +141,7 @@ export async function resolveTaskExecution( if (data.auto_pr !== undefined) { autoPr = data.auto_pr; } else { - const globalConfig = loadGlobalConfig(); + const { global: globalConfig } = loadConfig(defaultCwd); autoPr = globalConfig.autoPr ?? false; } diff --git a/src/features/tasks/execute/selectAndExecute.ts b/src/features/tasks/execute/selectAndExecute.ts index 78e5fe7..0dfa260 100644 --- a/src/features/tasks/execute/selectAndExecute.ts +++ b/src/features/tasks/execute/selectAndExecute.ts @@ -101,7 +101,7 @@ export async function selectAndExecuteTask( // Ask for PR creation BEFORE execution (only if worktree is enabled) let shouldCreatePr = false; if (isWorktree) { - shouldCreatePr = await resolveAutoPr(options?.autoPr); + shouldCreatePr = await resolveAutoPr(options?.autoPr, cwd); } log.info('Starting task execution', { piece: pieceIdentifier, worktree: isWorktree, autoPr: shouldCreatePr }); diff --git a/src/features/tasks/execute/session.ts b/src/features/tasks/execute/session.ts index 843fee8..9303f65 100644 --- a/src/features/tasks/execute/session.ts +++ b/src/features/tasks/execute/session.ts @@ -2,7 +2,7 @@ * Session management helpers for agent execution */ -import { loadPersonaSessions, updatePersonaSession, loadGlobalConfig } from '../../../infra/config/index.js'; +import { loadPersonaSessions, updatePersonaSession, loadConfig } from '../../../infra/config/index.js'; import type { AgentResponse } from '../../../core/models/index.js'; /** @@ -15,7 +15,7 @@ export async function withPersonaSession( fn: (sessionId?: string) => Promise, provider?: string ): Promise { - const resolvedProvider = provider ?? loadGlobalConfig().provider ?? 'claude'; + const resolvedProvider = provider ?? loadConfig(cwd).global.provider ?? 'claude'; const sessions = loadPersonaSessions(cwd, resolvedProvider); const sessionId = sessions[personaName]; diff --git a/src/features/tasks/execute/taskExecution.ts b/src/features/tasks/execute/taskExecution.ts index 63f2afd..a556fed 100644 --- a/src/features/tasks/execute/taskExecution.ts +++ b/src/features/tasks/execute/taskExecution.ts @@ -2,7 +2,7 @@ * Task execution logic */ -import { loadPieceByIdentifier, isPiecePath, loadGlobalConfig, loadProjectConfig } from '../../../infra/config/index.js'; +import { loadPieceByIdentifier, isPiecePath, loadConfig } from '../../../infra/config/index.js'; import { TaskRunner, type TaskInfo } from '../../../infra/task/index.js'; import { header, @@ -86,8 +86,9 @@ async function executeTaskWithResult(options: ExecuteTaskOptions): Promise s.name), }); - const globalConfig = loadGlobalConfig(); - const projectConfig = loadProjectConfig(projectCwd); + const config = loadConfig(projectCwd); + const globalConfig = config.global; + const projectConfig = config.project; return await executePiece(pieceConfig, task, cwd, { projectCwd, language: globalConfig.language, @@ -95,6 +96,8 @@ async function executeTaskWithResult(options: ExecuteTaskOptions): Promise { const taskRunner = new TaskRunner(cwd); - const globalConfig = loadGlobalConfig(); + const { global: globalConfig } = loadConfig(cwd); const shouldNotifyRunComplete = globalConfig.notificationSound !== false && globalConfig.notificationSoundEvents?.runComplete !== false; const shouldNotifyRunAbort = globalConfig.notificationSound !== false diff --git a/src/features/tasks/execute/types.ts b/src/features/tasks/execute/types.ts index ade69cd..7fd2f06 100644 --- a/src/features/tasks/execute/types.ts +++ b/src/features/tasks/execute/types.ts @@ -4,6 +4,7 @@ import type { Language } from '../../../core/models/index.js'; import type { ProviderPermissionProfiles } from '../../../core/models/provider-profiles.js'; +import type { MovementProviderOptions } from '../../../core/models/piece-types.js'; import type { ProviderType } from '../../../infra/providers/index.js'; import type { GitHubIssue } from '../../../infra/github/index.js'; @@ -37,6 +38,10 @@ export interface PieceExecutionOptions { /** Global config provider */ globalProvider?: ProviderType; model?: string; + /** Project-level provider options */ + projectProviderOptions?: MovementProviderOptions; + /** Global-level provider options */ + globalProviderOptions?: MovementProviderOptions; /** Per-persona provider overrides (e.g., { coder: 'codex' }) */ personaProviders?: Record; /** Project-level provider permission profiles */ diff --git a/src/features/tasks/list/instructMode.ts b/src/features/tasks/list/instructMode.ts index 6cc56b8..5bb0f5a 100644 --- a/src/features/tasks/list/instructMode.ts +++ b/src/features/tasks/list/instructMode.ts @@ -23,7 +23,7 @@ import { import { type RunSessionContext, formatRunSessionForPrompt } from '../../interactive/runSessionReader.js'; import { loadTemplate } from '../../../shared/prompts/index.js'; import { getLabelObject } from '../../../shared/i18n/index.js'; -import { loadGlobalConfig } from '../../../infra/config/index.js'; +import { loadConfig } from '../../../infra/config/index.js'; export type InstructModeAction = 'execute' | 'save_task' | 'cancel'; @@ -109,7 +109,7 @@ export async function runInstructMode( pieceContext?: PieceContext, runSessionContext?: RunSessionContext, ): Promise { - const globalConfig = loadGlobalConfig(); + const { global: globalConfig } = loadConfig(cwd); const lang = resolveLanguage(globalConfig.language); if (!globalConfig.provider) { diff --git a/src/features/tasks/list/taskInstructionActions.ts b/src/features/tasks/list/taskInstructionActions.ts index 578962a..b520bc6 100644 --- a/src/features/tasks/list/taskInstructionActions.ts +++ b/src/features/tasks/list/taskInstructionActions.ts @@ -11,7 +11,7 @@ import { TaskRunner, detectDefaultBranch, } from '../../../infra/task/index.js'; -import { loadGlobalConfig, getPieceDescription } from '../../../infra/config/index.js'; +import { loadConfig, 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 globalConfig = loadGlobalConfig(); + const { global: globalConfig } = loadConfig(projectDir); const pieceDesc = getPieceDescription(selectedPiece, projectDir, globalConfig.interactivePreviewMovements); const pieceContext: PieceContext = { name: pieceDesc.name, diff --git a/src/features/tasks/list/taskRetryActions.ts b/src/features/tasks/list/taskRetryActions.ts index 4a4bfce..9f36876 100644 --- a/src/features/tasks/list/taskRetryActions.ts +++ b/src/features/tasks/list/taskRetryActions.ts @@ -8,7 +8,7 @@ import * as fs from 'node:fs'; import type { TaskListItem } from '../../../infra/task/index.js'; import { TaskRunner } from '../../../infra/task/index.js'; -import { loadPieceByIdentifier, loadGlobalConfig, getPieceDescription } from '../../../infra/config/index.js'; +import { loadPieceByIdentifier, loadConfig, 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 globalConfig = loadGlobalConfig(); + const { global: globalConfig } = loadConfig(projectDir); const pieceConfig = loadPieceByIdentifier(selectedPiece, projectDir); if (!pieceConfig) { diff --git a/src/index.ts b/src/index.ts index bd9541d..416deb0 100644 --- a/src/index.ts +++ b/src/index.ts @@ -30,7 +30,9 @@ export { } from './infra/config/loaders/index.js'; export type { PieceSource, PieceWithSource, PieceDirEntry } from './infra/config/loaders/index.js'; export { - loadProjectConfig, + loadConfig, +} from './infra/config/loadConfig.js'; +export { saveProjectConfig, updateProjectConfig, getCurrentPiece, diff --git a/src/infra/claude/types.ts b/src/infra/claude/types.ts index 67114c6..1964aaa 100644 --- a/src/infra/claude/types.ts +++ b/src/infra/claude/types.ts @@ -141,7 +141,7 @@ export interface ClaudeCallOptions { onPermissionRequest?: PermissionHandler; /** Custom handler for AskUserQuestion tool */ onAskUserQuestion?: AskUserQuestionHandler; - /** Bypass all permission checks (sacrifice-my-pc mode) */ + /** Bypass all permission checks */ bypassPermissions?: boolean; /** Anthropic API key to inject via env (bypasses CLI auth) */ anthropicApiKey?: string; @@ -172,7 +172,7 @@ export interface ClaudeSpawnOptions { onPermissionRequest?: PermissionHandler; /** Custom handler for AskUserQuestion tool */ onAskUserQuestion?: AskUserQuestionHandler; - /** Bypass all permission checks (sacrifice-my-pc mode) */ + /** Bypass all permission checks */ bypassPermissions?: boolean; /** Anthropic API key to inject via env (bypasses CLI auth) */ anthropicApiKey?: string; diff --git a/src/infra/config/env/config-env-overrides.ts b/src/infra/config/env/config-env-overrides.ts new file mode 100644 index 0000000..db9df70 --- /dev/null +++ b/src/infra/config/env/config-env-overrides.ts @@ -0,0 +1,142 @@ +type EnvValueType = 'string' | 'boolean' | 'number' | 'json'; + +interface EnvSpec { + path: string; + type: EnvValueType; +} + +function normalizeEnvSegment(segment: string): string { + return segment + .replace(/([a-z0-9])([A-Z])/g, '$1_$2') + .replace(/[^a-zA-Z0-9]+/g, '_') + .replace(/_+/g, '_') + .replace(/^_|_$/g, '') + .toUpperCase(); +} + +export function envVarNameFromPath(path: string): string { + const key = path + .split('.') + .map(normalizeEnvSegment) + .filter((segment) => segment.length > 0) + .join('_'); + return `TAKT_${key}`; +} + +function parseEnvValue(envKey: string, raw: string, type: EnvValueType): unknown { + if (type === 'string') { + return raw; + } + if (type === 'boolean') { + 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`); + } + if (type === 'number') { + const trimmed = raw.trim(); + const value = Number(trimmed); + if (!Number.isFinite(value)) { + throw new Error(`${envKey} must be a number`); + } + return value; + } + try { + return JSON.parse(raw); + } catch { + throw new Error(`${envKey} must be valid JSON`); + } +} + +function setNested(target: Record, path: string, value: unknown): void { + const parts = path.split('.'); + let current: Record = target; + for (let i = 0; i < parts.length - 1; i++) { + const part = parts[i]; + if (!part) continue; + const next = current[part]; + if (typeof next !== 'object' || next === null || Array.isArray(next)) { + current[part] = {}; + } + current = current[part] as Record; + } + const leaf = parts[parts.length - 1]; + if (!leaf) return; + current[leaf] = value; +} + +function applyEnvOverrides(target: Record, specs: readonly EnvSpec[]): void { + for (const spec of specs) { + const envKey = envVarNameFromPath(spec.path); + const raw = process.env[envKey]; + if (raw === undefined) continue; + const parsedValue = parseEnvValue(envKey, raw, spec.type); + setNested(target, spec.path, parsedValue); + } +} + +const GLOBAL_ENV_SPECS: readonly EnvSpec[] = [ + { path: 'language', type: 'string' }, + { path: 'log_level', type: 'string' }, + { path: 'provider', type: 'string' }, + { path: 'model', type: 'string' }, + { path: 'observability', type: 'json' }, + { path: 'observability.provider_events', type: 'boolean' }, + { path: 'worktree_dir', type: 'string' }, + { path: 'auto_pr', type: 'boolean' }, + { path: 'disabled_builtins', type: 'json' }, + { path: 'enable_builtin_pieces', type: 'boolean' }, + { path: 'anthropic_api_key', type: 'string' }, + { path: 'openai_api_key', type: 'string' }, + { path: 'codex_cli_path', type: 'string' }, + { path: 'opencode_api_key', type: 'string' }, + { path: 'pipeline', type: 'json' }, + { path: 'pipeline.default_branch_prefix', type: 'string' }, + { path: 'pipeline.commit_message_template', type: 'string' }, + { path: 'pipeline.pr_body_template', type: 'string' }, + { path: 'minimal_output', type: 'boolean' }, + { path: 'bookmarks_file', type: 'string' }, + { path: 'piece_categories_file', type: 'string' }, + { path: 'persona_providers', type: 'json' }, + { path: 'provider_options', type: 'json' }, + { path: 'provider_options.codex.network_access', type: 'boolean' }, + { path: 'provider_options.opencode.network_access', type: 'boolean' }, + { path: 'provider_options.claude.sandbox.allow_unsandboxed_commands', type: 'boolean' }, + { path: 'provider_options.claude.sandbox.excluded_commands', type: 'json' }, + { path: 'provider_profiles', type: 'json' }, + { path: 'runtime', type: 'json' }, + { path: 'runtime.prepare', type: 'json' }, + { path: 'branch_name_strategy', type: 'string' }, + { path: 'prevent_sleep', type: 'boolean' }, + { path: 'notification_sound', type: 'boolean' }, + { path: 'notification_sound_events', type: 'json' }, + { path: 'notification_sound_events.iteration_limit', type: 'boolean' }, + { path: 'notification_sound_events.piece_complete', type: 'boolean' }, + { path: 'notification_sound_events.piece_abort', type: 'boolean' }, + { path: 'notification_sound_events.run_complete', type: 'boolean' }, + { path: 'notification_sound_events.run_abort', type: 'boolean' }, + { path: 'interactive_preview_movements', type: 'number' }, + { path: 'verbose', type: 'boolean' }, + { path: 'concurrency', type: 'number' }, + { path: 'task_poll_interval_ms', type: 'number' }, +]; + +const PROJECT_ENV_SPECS: readonly EnvSpec[] = [ + { path: 'piece', type: 'string' }, + { path: 'provider', type: 'string' }, + { path: 'verbose', type: 'boolean' }, + { path: 'provider_options', type: 'json' }, + { path: 'provider_options.codex.network_access', type: 'boolean' }, + { path: 'provider_options.opencode.network_access', type: 'boolean' }, + { path: 'provider_options.claude.sandbox.allow_unsandboxed_commands', type: 'boolean' }, + { path: 'provider_options.claude.sandbox.excluded_commands', type: 'json' }, + { path: 'provider_profiles', type: 'json' }, +]; + +export function applyGlobalConfigEnvOverrides(target: Record): void { + applyEnvOverrides(target, GLOBAL_ENV_SPECS); +} + +export function applyProjectConfigEnvOverrides(target: Record): void { + applyEnvOverrides(target, PROJECT_ENV_SPECS); +} diff --git a/src/infra/config/global/globalConfig.ts b/src/infra/config/global/globalConfig.ts index f233245..aefeb46 100644 --- a/src/infra/config/global/globalConfig.ts +++ b/src/infra/config/global/globalConfig.ts @@ -15,6 +15,7 @@ import { normalizeProviderOptions } from '../loaders/pieceParser.js'; import { getGlobalConfigPath } from '../paths.js'; import { DEFAULT_LANGUAGE } from '../../../shared/constants.js'; import { parseProviderModel } from '../../../shared/utils/providerModel.js'; +import { applyGlobalConfigEnvOverrides, envVarNameFromPath } from '../env/config-env-overrides.js'; /** Claude-specific model aliases that are not valid for other providers */ const CLAUDE_MODEL_ALIASES = new Set(['opus', 'sonnet', 'haiku']); @@ -107,20 +108,6 @@ function denormalizeProviderProfiles( }])) as Record }>; } -/** Create default global configuration (fresh instance each call) */ -function createDefaultGlobalConfig(): GlobalConfig { - return { - language: DEFAULT_LANGUAGE, - defaultPiece: 'default', - logLevel: 'info', - provider: 'claude', - enableBuiltinPieces: true, - interactivePreviewMovements: 3, - concurrency: 1, - taskPollIntervalMs: 500, - }; -} - /** * Manages global configuration loading and caching. * Singleton — use GlobalConfigManager.getInstance(). @@ -154,17 +141,23 @@ export class GlobalConfigManager { return this.cachedConfig; } const configPath = getGlobalConfigPath(); - if (!existsSync(configPath)) { - const defaultConfig = createDefaultGlobalConfig(); - this.cachedConfig = defaultConfig; - return defaultConfig; + + const rawConfig: Record = {}; + if (existsSync(configPath)) { + const content = readFileSync(configPath, 'utf-8'); + const parsedRaw = parseYaml(content); + if (parsedRaw && typeof parsedRaw === 'object' && !Array.isArray(parsedRaw)) { + Object.assign(rawConfig, parsedRaw as Record); + } else if (parsedRaw != null) { + throw new Error('Configuration error: ~/.takt/config.yaml must be a YAML object.'); + } } - const content = readFileSync(configPath, 'utf-8'); - const raw = parseYaml(content); - const parsed = GlobalConfigSchema.parse(raw); + + applyGlobalConfigEnvOverrides(rawConfig); + + const parsed = GlobalConfigSchema.parse(rawConfig); const config: GlobalConfig = { language: parsed.language, - defaultPiece: parsed.default_piece, logLevel: parsed.log_level, provider: parsed.provider, model: parsed.model, @@ -204,6 +197,7 @@ export class GlobalConfigManager { runAbort: parsed.notification_sound_events.run_abort, } : undefined, interactivePreviewMovements: parsed.interactive_preview_movements, + verbose: parsed.verbose, concurrency: parsed.concurrency, taskPollIntervalMs: parsed.task_poll_interval_ms, }; @@ -217,7 +211,6 @@ export class GlobalConfigManager { const configPath = getGlobalConfigPath(); const raw: Record = { language: config.language, - default_piece: config.defaultPiece, log_level: config.logLevel, provider: config.provider, }; @@ -316,6 +309,9 @@ export class GlobalConfigManager { if (config.interactivePreviewMovements !== undefined) { raw.interactive_preview_movements = config.interactivePreviewMovements; } + if (config.verbose !== undefined) { + raw.verbose = config.verbose; + } if (config.concurrency !== undefined && config.concurrency > 1) { raw.concurrency = config.concurrency; } @@ -383,7 +379,7 @@ export function setProvider(provider: 'claude' | 'codex' | 'opencode'): void { * Priority: TAKT_ANTHROPIC_API_KEY env var > config.yaml > undefined (CLI auth fallback) */ export function resolveAnthropicApiKey(): string | undefined { - const envKey = process.env['TAKT_ANTHROPIC_API_KEY']; + const envKey = process.env[envVarNameFromPath('anthropic_api_key')]; if (envKey) return envKey; try { @@ -399,7 +395,7 @@ export function resolveAnthropicApiKey(): string | undefined { * Priority: TAKT_OPENAI_API_KEY env var > config.yaml > undefined (CLI auth fallback) */ export function resolveOpenaiApiKey(): string | undefined { - const envKey = process.env['TAKT_OPENAI_API_KEY']; + const envKey = process.env[envVarNameFromPath('openai_api_key')]; if (envKey) return envKey; try { @@ -415,7 +411,7 @@ export function resolveOpenaiApiKey(): string | undefined { * Priority: TAKT_CODEX_CLI_PATH env var > config.yaml > undefined (SDK vendored binary fallback) */ export function resolveCodexCliPath(): string | undefined { - const envPath = process.env['TAKT_CODEX_CLI_PATH']; + const envPath = process.env[envVarNameFromPath('codex_cli_path')]; if (envPath !== undefined) { return validateCodexCliPath(envPath, 'TAKT_CODEX_CLI_PATH'); } @@ -437,7 +433,7 @@ export function resolveCodexCliPath(): string | undefined { * Priority: TAKT_OPENCODE_API_KEY env var > config.yaml > undefined */ export function resolveOpencodeApiKey(): string | undefined { - const envKey = process.env['TAKT_OPENCODE_API_KEY']; + const envKey = process.env[envVarNameFromPath('opencode_api_key')]; if (envKey) return envKey; try { @@ -447,4 +443,3 @@ export function resolveOpencodeApiKey(): string | undefined { return undefined; } } - diff --git a/src/infra/config/index.ts b/src/infra/config/index.ts index 03378b8..0045bfe 100644 --- a/src/infra/config/index.ts +++ b/src/infra/config/index.ts @@ -6,3 +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'; diff --git a/src/infra/config/loadConfig.ts b/src/infra/config/loadConfig.ts new file mode 100644 index 0000000..85edeff --- /dev/null +++ b/src/infra/config/loadConfig.ts @@ -0,0 +1,16 @@ +import type { GlobalConfig } from '../../core/models/index.js'; +import type { ProjectLocalConfig } from './project/projectConfig.js'; +import { loadGlobalConfig } from './global/globalConfig.js'; +import { loadProjectConfig } from './project/projectConfig.js'; + +export interface LoadedConfig { + global: GlobalConfig; + project: ProjectLocalConfig; +} + +export function loadConfig(projectDir: string): LoadedConfig { + return { + global: loadGlobalConfig(), + project: loadProjectConfig(projectDir), + }; +} diff --git a/src/infra/config/paths.ts b/src/infra/config/paths.ts index ec29c58..ccde49f 100644 --- a/src/infra/config/paths.ts +++ b/src/infra/config/paths.ts @@ -119,9 +119,11 @@ export { updateProjectConfig, getCurrentPiece, setCurrentPiece, - isVerboseMode, type ProjectLocalConfig, } from './project/projectConfig.js'; +export { + isVerboseMode, +} from './project/resolvedSettings.js'; // Re-export session storage functions export { diff --git a/src/infra/config/project/index.ts b/src/infra/config/project/index.ts index cb88e8d..4476961 100644 --- a/src/infra/config/project/index.ts +++ b/src/infra/config/project/index.ts @@ -8,10 +8,11 @@ export { updateProjectConfig, getCurrentPiece, setCurrentPiece, - isVerboseMode, - type PermissionMode, type ProjectLocalConfig, } from './projectConfig.js'; +export { + isVerboseMode, +} from './resolvedSettings.js'; export { writeFileAtomic, diff --git a/src/infra/config/project/projectConfig.ts b/src/infra/config/project/projectConfig.ts index 83e7b8a..fa2d7e6 100644 --- a/src/infra/config/project/projectConfig.ts +++ b/src/infra/config/project/projectConfig.ts @@ -8,15 +8,16 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'; import { join, resolve } from 'node:path'; import { parse, stringify } from 'yaml'; import { copyProjectResourcesToDir } from '../../resources/index.js'; -import type { PermissionMode, ProjectLocalConfig } from '../types.js'; +import type { ProjectLocalConfig } from '../types.js'; import type { ProviderPermissionProfiles } from '../../../core/models/provider-profiles.js'; +import { applyProjectConfigEnvOverrides } from '../env/config-env-overrides.js'; +import { normalizeProviderOptions } from '../loaders/pieceParser.js'; -export type { PermissionMode, ProjectLocalConfig }; +export type { ProjectLocalConfig } from '../types.js'; /** Default project configuration */ const DEFAULT_PROJECT_CONFIG: ProjectLocalConfig = { piece: 'default', - permissionMode: 'default', }; /** @@ -63,21 +64,34 @@ function denormalizeProviderProfiles(profiles: ProviderPermissionProfiles | unde export function loadProjectConfig(projectDir: string): ProjectLocalConfig { const configPath = getConfigPath(projectDir); - if (!existsSync(configPath)) { - return { ...DEFAULT_PROJECT_CONFIG }; + const parsedConfig: Record = {}; + if (existsSync(configPath)) { + try { + const content = readFileSync(configPath, 'utf-8'); + const parsed = (parse(content) as Record | null) ?? {}; + Object.assign(parsedConfig, parsed); + } catch { + return { ...DEFAULT_PROJECT_CONFIG }; + } } - try { - const content = readFileSync(configPath, 'utf-8'); - const parsed = (parse(content) as ProjectLocalConfig | null) ?? {}; - return { - ...DEFAULT_PROJECT_CONFIG, - ...parsed, - providerProfiles: normalizeProviderProfiles(parsed.provider_profiles as Record }> | undefined), - }; - } catch { - return { ...DEFAULT_PROJECT_CONFIG }; - } + applyProjectConfigEnvOverrides(parsedConfig); + + return { + ...DEFAULT_PROJECT_CONFIG, + ...(parsedConfig as ProjectLocalConfig), + providerOptions: normalizeProviderOptions(parsedConfig.provider_options as { + codex?: { network_access?: boolean }; + opencode?: { network_access?: boolean }; + claude?: { + sandbox?: { + allow_unsandboxed_commands?: boolean; + excluded_commands?: string[]; + }; + }; + } | undefined), + providerProfiles: normalizeProviderProfiles(parsedConfig.provider_profiles as Record }> | undefined), + }; } /** @@ -103,6 +117,7 @@ export function saveProjectConfig(projectDir: string, config: ProjectLocalConfig delete savePayload.provider_profiles; } delete savePayload.providerProfiles; + delete savePayload.providerOptions; const content = stringify(savePayload, { indent: 2 }); writeFileSync(configPath, content, 'utf-8'); @@ -135,11 +150,3 @@ export function getCurrentPiece(projectDir: string): string { export function setCurrentPiece(projectDir: string, piece: string): void { updateProjectConfig(projectDir, 'piece', piece); } - -/** - * Get verbose mode from project config - */ -export function isVerboseMode(projectDir: string): boolean { - const config = loadProjectConfig(projectDir); - return config.verbose === true; -} diff --git a/src/infra/config/project/resolvedSettings.ts b/src/infra/config/project/resolvedSettings.ts new file mode 100644 index 0000000..f67fc71 --- /dev/null +++ b/src/infra/config/project/resolvedSettings.ts @@ -0,0 +1,32 @@ +import { envVarNameFromPath } from '../env/config-env-overrides.js'; +import { loadConfig } from '../loadConfig.js'; + +function resolveValue( + 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 { + const envValue = loadEnvBooleanSetting('verbose'); + const { project, global } = loadConfig(projectDir); + return resolveValue(envValue, project.verbose, global.verbose, false); +} diff --git a/src/infra/config/types.ts b/src/infra/config/types.ts index f7a31d7..ae83e49 100644 --- a/src/infra/config/types.ts +++ b/src/infra/config/types.ts @@ -2,40 +2,25 @@ * Config module type definitions */ -import type { PieceCategoryConfigNode } from '../../core/models/schemas.js'; import type { MovementProviderOptions } from '../../core/models/piece-types.js'; import type { ProviderPermissionProfiles } from '../../core/models/provider-profiles.js'; -/** Permission mode for the project - * - default: Uses Agent SDK's acceptEdits mode (auto-accepts file edits, minimal prompts) - * - sacrifice-my-pc: Auto-approves all permission requests (bypassPermissions) - * - * Note: 'confirm' mode is planned but not yet implemented - */ -export type PermissionMode = 'default' | 'sacrifice-my-pc'; - /** Project configuration stored in .takt/config.yaml */ export interface ProjectLocalConfig { /** Current piece name */ piece?: string; /** Provider selection for agent runtime */ provider?: 'claude' | 'codex' | 'opencode'; - /** Permission mode setting */ - permissionMode?: PermissionMode; /** Verbose output mode */ verbose?: boolean; /** Provider-specific options (overrides global, overridden by piece/movement) */ provider_options?: MovementProviderOptions; + /** Provider-specific options (camelCase alias) */ + providerOptions?: MovementProviderOptions; /** Provider-specific permission profiles (project-level override) */ provider_profiles?: ProviderPermissionProfiles; /** Provider-specific permission profiles (camelCase alias) */ providerProfiles?: ProviderPermissionProfiles; - /** Piece categories (name -> piece list) */ - piece_categories?: Record; - /** Show uncategorized pieces under Others category */ - show_others_category?: boolean; - /** Display name for Others category */ - others_category_name?: string; /** Custom settings */ [key: string]: unknown; } diff --git a/src/infra/task/clone.ts b/src/infra/task/clone.ts index 9a2d393..e1dd846 100644 --- a/src/infra/task/clone.ts +++ b/src/infra/task/clone.ts @@ -11,7 +11,7 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import { execFileSync } from 'node:child_process'; import { createLogger, slugify } from '../../shared/utils/index.js'; -import { loadGlobalConfig } from '../config/global/globalConfig.js'; +import { loadConfig } from '../config/index.js'; import type { WorktreeOptions, WorktreeResult } from './types.js'; export type { WorktreeOptions, WorktreeResult }; @@ -36,7 +36,7 @@ export class CloneManager { * Returns the configured worktree_dir (resolved to absolute), or ../ */ private static resolveCloneBaseDir(projectDir: string): string { - const globalConfig = loadGlobalConfig(); + const { global: globalConfig } = loadConfig(projectDir); if (globalConfig.worktreeDir) { return path.isAbsolute(globalConfig.worktreeDir) ? globalConfig.worktreeDir diff --git a/src/infra/task/summarize.ts b/src/infra/task/summarize.ts index a8c8041..3da5547 100644 --- a/src/infra/task/summarize.ts +++ b/src/infra/task/summarize.ts @@ -5,7 +5,7 @@ */ import * as wanakana from 'wanakana'; -import { loadGlobalConfig } from '../config/global/globalConfig.js'; +import { loadConfig } from '../config/index.js'; import { getProvider, type ProviderType } from '../providers/index.js'; import { createLogger } from '../../shared/utils/index.js'; import { loadTemplate } from '../../shared/prompts/index.js'; @@ -53,7 +53,7 @@ export class TaskSummarizer { taskName: string, options: SummarizeOptions, ): Promise { - const globalConfig = loadGlobalConfig(); + const { global: globalConfig } = loadConfig(options.cwd); const useLLM = options.useLLM ?? (globalConfig.branchNameStrategy === 'ai'); log.info('Summarizing task name', { taskName, useLLM });