refactor: 設定解決をloadConfigへ統一し不要設定を削除

This commit is contained in:
nrslib 2026-02-19 10:32:59 +09:00
parent faf6ebf063
commit 5dc79946f2
50 changed files with 1219 additions and 326 deletions

View File

@ -76,7 +76,7 @@ vi.mock('../infra/task/index.js', () => ({
vi.mock('../infra/config/index.js', () => ({ vi.mock('../infra/config/index.js', () => ({
getPieceDescription: vi.fn(() => ({ name: 'default', description: 'test piece', pieceStructure: '', movementPreviews: [] })), 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', () => ({ 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 { checkGhCli, fetchIssue, formatIssueAsTask, parseIssueNumbers } from '../infra/github/issue.js';
import { selectAndExecuteTask, determinePiece, createIssueFromTask, saveTaskFromInteractive } from '../features/tasks/index.js'; import { selectAndExecuteTask, determinePiece, createIssueFromTask, saveTaskFromInteractive } from '../features/tasks/index.js';
import { interactiveMode, selectRecentSession } from '../features/interactive/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 { confirm } from '../shared/prompt/index.js';
import { isDirectTask } from '../app/cli/helpers.js'; import { isDirectTask } from '../app/cli/helpers.js';
import { executeDefaultAction } from '../app/cli/routing.js'; import { executeDefaultAction } from '../app/cli/routing.js';
@ -123,7 +123,7 @@ const mockCreateIssueFromTask = vi.mocked(createIssueFromTask);
const mockSaveTaskFromInteractive = vi.mocked(saveTaskFromInteractive); const mockSaveTaskFromInteractive = vi.mocked(saveTaskFromInteractive);
const mockInteractiveMode = vi.mocked(interactiveMode); const mockInteractiveMode = vi.mocked(interactiveMode);
const mockSelectRecentSession = vi.mocked(selectRecentSession); const mockSelectRecentSession = vi.mocked(selectRecentSession);
const mockLoadGlobalConfig = vi.mocked(loadGlobalConfig); const mockLoadConfig = vi.mocked(loadConfig);
const mockConfirm = vi.mocked(confirm); const mockConfirm = vi.mocked(confirm);
const mockIsDirectTask = vi.mocked(isDirectTask); const mockIsDirectTask = vi.mocked(isDirectTask);
const mockTaskRunnerListAllTaskItems = vi.mocked(mockListAllTaskItems); const mockTaskRunnerListAllTaskItems = vi.mocked(mockListAllTaskItems);
@ -483,7 +483,7 @@ describe('Issue resolution in routing', () => {
describe('session selection with provider=claude', () => { describe('session selection with provider=claude', () => {
it('should pass selected session ID to interactiveMode when provider is claude', async () => { it('should pass selected session ID to interactiveMode when provider is claude', async () => {
// Given // Given
mockLoadGlobalConfig.mockReturnValue({ interactivePreviewMovements: 3, provider: 'claude' }); mockLoadConfig.mockReturnValue({ global: { interactivePreviewMovements: 3, provider: 'claude' }, project: {} });
mockConfirm.mockResolvedValue(true); mockConfirm.mockResolvedValue(true);
mockSelectRecentSession.mockResolvedValue('session-xyz'); 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 () => { it('should not call selectRecentSession when user selects no in confirmation', async () => {
// Given // Given
mockLoadGlobalConfig.mockReturnValue({ interactivePreviewMovements: 3, provider: 'claude' }); mockLoadConfig.mockReturnValue({ global: { interactivePreviewMovements: 3, provider: 'claude' }, project: {} });
mockConfirm.mockResolvedValue(false); mockConfirm.mockResolvedValue(false);
// When // When
@ -525,7 +525,7 @@ describe('Issue resolution in routing', () => {
it('should not call selectRecentSession when provider is not claude', async () => { it('should not call selectRecentSession when provider is not claude', async () => {
// Given // Given
mockLoadGlobalConfig.mockReturnValue({ interactivePreviewMovements: 3, provider: 'openai' }); mockLoadConfig.mockReturnValue({ global: { interactivePreviewMovements: 3, provider: 'openai' }, project: {} });
// When // When
await executeDefaultAction(); await executeDefaultAction();

View File

@ -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<string, unknown> = {};
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<string, unknown> = {};
applyProjectConfigEnvOverrides(raw);
expect(raw.verbose).toBe(true);
});
});

View File

@ -35,6 +35,8 @@ import {
updateWorktreeSession, updateWorktreeSession,
getLanguage, getLanguage,
loadProjectConfig, loadProjectConfig,
isVerboseMode,
invalidateGlobalConfigCache,
} from '../infra/config/index.js'; } from '../infra/config/index.js';
describe('getBuiltinPiece', () => { 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', () => { describe('loadInputHistory', () => {
let testDir: string; let testDir: string;

View File

@ -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<Record<string, unknown>>()),
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 },
});
});
});

View File

@ -39,7 +39,6 @@ describe('loadGlobalConfig', () => {
const config = loadGlobalConfig(); const config = loadGlobalConfig();
expect(config.language).toBe('en'); expect(config.language).toBe('en');
expect(config.defaultPiece).toBe('default');
expect(config.logLevel).toBe('info'); expect(config.logLevel).toBe('info');
expect(config.provider).toBe('claude'); expect(config.provider).toBe('claude');
expect(config.model).toBeUndefined(); expect(config.model).toBeUndefined();
@ -79,6 +78,23 @@ describe('loadGlobalConfig', () => {
expect(config.logLevel).toBe('debug'); 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', () => { it('should load pipeline config from config.yaml', () => {
const taktDir = join(testHomeDir, '.takt'); const taktDir = join(testHomeDir, '.takt');
mkdirSync(taktDir, { recursive: true }); mkdirSync(taktDir, { recursive: true });

View File

@ -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<typeof import('../agents/ai-judge.js')>();
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<Record<string, unknown>>()),
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 },
});
});
});

View File

@ -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<typeof import('../agents/ai-judge.js')>();
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<Record<string, unknown>>()),
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 },
});
});
});

View File

@ -495,7 +495,6 @@ describe('GlobalConfigSchema', () => {
const config = {}; const config = {};
const result = GlobalConfigSchema.parse(config); const result = GlobalConfigSchema.parse(config);
expect(result.default_piece).toBe('default');
expect(result.log_level).toBe('info'); expect(result.log_level).toBe('info');
expect(result.provider).toBe('claude'); expect(result.provider).toBe('claude');
expect(result.observability).toBeUndefined(); expect(result.observability).toBeUndefined();
@ -503,7 +502,6 @@ describe('GlobalConfigSchema', () => {
it('should accept valid config', () => { it('should accept valid config', () => {
const config = { const config = {
default_piece: 'custom',
log_level: 'debug' as const, log_level: 'debug' as const,
observability: { observability: {
provider_events: false, provider_events: false,

View File

@ -2,8 +2,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
const { const {
getProviderMock, getProviderMock,
loadProjectConfigMock, loadConfigMock,
loadGlobalConfigMock,
loadCustomAgentsMock, loadCustomAgentsMock,
loadAgentPromptMock, loadAgentPromptMock,
loadTemplateMock, loadTemplateMock,
@ -15,8 +14,7 @@ const {
return { return {
getProviderMock: vi.fn(() => ({ setup: providerSetup })), getProviderMock: vi.fn(() => ({ setup: providerSetup })),
loadProjectConfigMock: vi.fn(), loadConfigMock: vi.fn(),
loadGlobalConfigMock: vi.fn(),
loadCustomAgentsMock: vi.fn(), loadCustomAgentsMock: vi.fn(),
loadAgentPromptMock: vi.fn(), loadAgentPromptMock: vi.fn(),
loadTemplateMock: vi.fn(), loadTemplateMock: vi.fn(),
@ -30,8 +28,7 @@ vi.mock('../infra/providers/index.js', () => ({
})); }));
vi.mock('../infra/config/index.js', () => ({ vi.mock('../infra/config/index.js', () => ({
loadProjectConfig: loadProjectConfigMock, loadConfig: loadConfigMock,
loadGlobalConfig: loadGlobalConfigMock,
loadCustomAgents: loadCustomAgentsMock, loadCustomAgents: loadCustomAgentsMock,
loadAgentPrompt: loadAgentPromptMock, loadAgentPrompt: loadAgentPromptMock,
})); }));
@ -47,8 +44,7 @@ describe('option resolution order', () => {
vi.clearAllMocks(); vi.clearAllMocks();
providerCallMock.mockResolvedValue({ content: 'ok' }); providerCallMock.mockResolvedValue({ content: 'ok' });
loadProjectConfigMock.mockReturnValue({}); loadConfigMock.mockReturnValue({ global: {}, project: {} });
loadGlobalConfigMock.mockReturnValue({});
loadCustomAgentsMock.mockReturnValue(new Map()); loadCustomAgentsMock.mockReturnValue(new Map());
loadAgentPromptMock.mockReturnValue('prompt'); loadAgentPromptMock.mockReturnValue('prompt');
loadTemplateMock.mockReturnValue('template'); loadTemplateMock.mockReturnValue('template');
@ -56,8 +52,10 @@ describe('option resolution order', () => {
it('should resolve provider in order: CLI > Local > Piece(step) > Global', async () => { it('should resolve provider in order: CLI > Local > Piece(step) > Global', async () => {
// Given // Given
loadProjectConfigMock.mockReturnValue({ provider: 'opencode' }); loadConfigMock.mockReturnValue({
loadGlobalConfigMock.mockReturnValue({ provider: 'mock' }); project: { provider: 'opencode' },
global: { provider: 'mock' },
});
// When: CLI provider が指定される // When: CLI provider が指定される
await runAgent(undefined, 'task', { await runAgent(undefined, 'task', {
@ -79,7 +77,10 @@ describe('option resolution order', () => {
expect(getProviderMock).toHaveBeenLastCalledWith('opencode'); expect(getProviderMock).toHaveBeenLastCalledWith('opencode');
// When: Local なしPiece が有効) // When: Local なしPiece が有効)
loadProjectConfigMock.mockReturnValue({}); loadConfigMock.mockReturnValue({
project: {},
global: { provider: 'mock' },
});
await runAgent(undefined, 'task', { await runAgent(undefined, 'task', {
cwd: '/repo', cwd: '/repo',
stepProvider: 'claude', stepProvider: 'claude',
@ -97,8 +98,10 @@ describe('option resolution order', () => {
it('should resolve model in order: CLI > Piece(step) > Global(matching provider)', async () => { it('should resolve model in order: CLI > Piece(step) > Global(matching provider)', async () => {
// Given // Given
loadProjectConfigMock.mockReturnValue({ provider: 'claude' }); loadConfigMock.mockReturnValue({
loadGlobalConfigMock.mockReturnValue({ provider: 'claude', model: 'global-model' }); project: { provider: 'claude' },
global: { provider: 'claude', model: 'global-model' },
});
// When: CLI model あり // When: CLI model あり
await runAgent(undefined, 'task', { 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 () => { it('should ignore global model when global provider does not match resolved provider', async () => {
// Given // Given
loadProjectConfigMock.mockReturnValue({ provider: 'codex' }); loadConfigMock.mockReturnValue({
loadGlobalConfigMock.mockReturnValue({ provider: 'claude', model: 'global-model' }); project: { provider: 'codex' },
global: { provider: 'claude', model: 'global-model' },
});
// When // When
await runAgent(undefined, 'task', { cwd: '/repo' }); await runAgent(undefined, 'task', { cwd: '/repo' });
@ -160,16 +165,15 @@ describe('option resolution order', () => {
}, },
}; };
loadProjectConfigMock.mockReturnValue({ loadConfigMock.mockReturnValue({
provider: 'claude', project: {
provider_options: { provider: 'claude',
claude: { sandbox: { allow_unsandboxed_commands: true } },
}, },
}); global: {
loadGlobalConfigMock.mockReturnValue({ provider: 'claude',
provider: 'claude', providerOptions: {
providerOptions: { claude: { sandbox: { allowUnsandboxedCommands: true } },
claude: { sandbox: { allowUnsandboxedCommands: true } }, },
}, },
}); });

View File

@ -69,6 +69,56 @@ describe('OptionsBuilder.buildBaseOptions', () => {
const options = builder.buildBaseOptions(step); const options = builder.buildBaseOptions(step);
expect(options.permissionMode).toBe('edit'); 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', () => { describe('OptionsBuilder.buildResumeOptions', () => {

View File

@ -5,25 +5,33 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { TaskInfo } from '../infra/task/index.js'; import type { TaskInfo } from '../infra/task/index.js';
// Mock dependencies before importing the module under test const { mockLoadConfigRaw } = vi.hoisted(() => ({
vi.mock('../infra/config/index.js', () => ({ mockLoadConfigRaw: vi.fn(() => ({
loadPieceByIdentifier: vi.fn(),
isPiecePath: vi.fn(() => false),
loadGlobalConfig: vi.fn(() => ({
language: 'en', language: 'en',
defaultPiece: 'default', defaultPiece: 'default',
logLevel: 'info', logLevel: 'info',
concurrency: 1, concurrency: 1,
taskPollIntervalMs: 500, taskPollIntervalMs: 500,
})), })),
loadProjectConfig: vi.fn(() => ({
piece: 'default',
permissionMode: 'default',
})),
})); }));
import { loadGlobalConfig } from '../infra/config/index.js'; // Mock dependencies before importing the module under test
const mockLoadGlobalConfig = vi.mocked(loadGlobalConfig); vi.mock('../infra/config/index.js', () => ({
loadPieceByIdentifier: vi.fn(),
isPiecePath: vi.fn(() => false),
loadConfig: (...args: unknown[]) => {
const raw = mockLoadConfigRaw(...args) as Record<string, unknown>;
if ('global' in raw && 'project' in raw) {
return raw;
}
return {
global: raw,
project: { piece: 'default' },
};
},
}));
const mockLoadConfig = mockLoadConfigRaw;
const { const {
mockClaimNextTasks, mockClaimNextTasks,
@ -167,7 +175,7 @@ beforeEach(() => {
describe('runAllTasks concurrency', () => { describe('runAllTasks concurrency', () => {
describe('sequential execution (concurrency=1)', () => { describe('sequential execution (concurrency=1)', () => {
beforeEach(() => { beforeEach(() => {
mockLoadGlobalConfig.mockReturnValue({ mockLoadConfig.mockReturnValue({
language: 'en', language: 'en',
defaultPiece: 'default', defaultPiece: 'default',
logLevel: 'info', logLevel: 'info',
@ -210,7 +218,7 @@ describe('runAllTasks concurrency', () => {
describe('parallel execution (concurrency>1)', () => { describe('parallel execution (concurrency>1)', () => {
beforeEach(() => { beforeEach(() => {
mockLoadGlobalConfig.mockReturnValue({ mockLoadConfig.mockReturnValue({
language: 'en', language: 'en',
defaultPiece: 'default', defaultPiece: 'default',
logLevel: 'info', logLevel: 'info',
@ -288,7 +296,7 @@ describe('runAllTasks concurrency', () => {
describe('default concurrency', () => { describe('default concurrency', () => {
it('should default to sequential when concurrency is not set', async () => { it('should default to sequential when concurrency is not set', async () => {
// Given: Config without explicit concurrency (defaults to 1) // Given: Config without explicit concurrency (defaults to 1)
mockLoadGlobalConfig.mockReturnValue({ mockLoadConfig.mockReturnValue({
language: 'en', language: 'en',
defaultPiece: 'default', defaultPiece: 'default',
logLevel: 'info', logLevel: 'info',
@ -324,7 +332,7 @@ describe('runAllTasks concurrency', () => {
}; };
beforeEach(() => { beforeEach(() => {
mockLoadGlobalConfig.mockReturnValue({ mockLoadConfig.mockReturnValue({
language: 'en', language: 'en',
defaultPiece: 'default', defaultPiece: 'default',
logLevel: 'info', logLevel: 'info',
@ -371,7 +379,7 @@ describe('runAllTasks concurrency', () => {
it('should fill slots immediately when a task completes (no batch waiting)', async () => { it('should fill slots immediately when a task completes (no batch waiting)', async () => {
// Given: 3 tasks, concurrency=2, task1 finishes quickly, task2 takes longer // Given: 3 tasks, concurrency=2, task1 finishes quickly, task2 takes longer
mockLoadGlobalConfig.mockReturnValue({ mockLoadConfig.mockReturnValue({
language: 'en', language: 'en',
defaultPiece: 'default', defaultPiece: 'default',
logLevel: 'info', logLevel: 'info',
@ -413,7 +421,7 @@ describe('runAllTasks concurrency', () => {
it('should count partial failures correctly', async () => { it('should count partial failures correctly', async () => {
// Given: 3 tasks, 1 fails, 2 succeed // Given: 3 tasks, 1 fails, 2 succeed
mockLoadGlobalConfig.mockReturnValue({ mockLoadConfig.mockReturnValue({
language: 'en', language: 'en',
defaultPiece: 'default', defaultPiece: 'default',
logLevel: 'info', logLevel: 'info',
@ -495,7 +503,7 @@ describe('runAllTasks concurrency', () => {
it('should pass abortSignal but not taskPrefix in sequential mode', async () => { it('should pass abortSignal but not taskPrefix in sequential mode', async () => {
// Given: Sequential mode // Given: Sequential mode
mockLoadGlobalConfig.mockReturnValue({ mockLoadConfig.mockReturnValue({
language: 'en', language: 'en',
defaultPiece: 'default', defaultPiece: 'default',
logLevel: 'info', logLevel: 'info',
@ -525,7 +533,7 @@ describe('runAllTasks concurrency', () => {
}); });
it('should only notify once at run completion when multiple tasks succeed', async () => { it('should only notify once at run completion when multiple tasks succeed', async () => {
mockLoadGlobalConfig.mockReturnValue({ mockLoadConfig.mockReturnValue({
language: 'en', language: 'en',
defaultPiece: 'default', defaultPiece: 'default',
logLevel: 'info', logLevel: 'info',
@ -550,7 +558,7 @@ describe('runAllTasks concurrency', () => {
}); });
it('should not notify run completion when runComplete is explicitly false', async () => { it('should not notify run completion when runComplete is explicitly false', async () => {
mockLoadGlobalConfig.mockReturnValue({ mockLoadConfig.mockReturnValue({
language: 'en', language: 'en',
defaultPiece: 'default', defaultPiece: 'default',
logLevel: 'info', logLevel: 'info',
@ -572,7 +580,7 @@ describe('runAllTasks concurrency', () => {
}); });
it('should notify run completion by default when notification_sound_events is not set', async () => { it('should notify run completion by default when notification_sound_events is not set', async () => {
mockLoadGlobalConfig.mockReturnValue({ mockLoadConfig.mockReturnValue({
language: 'en', language: 'en',
defaultPiece: 'default', defaultPiece: 'default',
logLevel: 'info', logLevel: 'info',
@ -594,7 +602,7 @@ describe('runAllTasks concurrency', () => {
}); });
it('should notify run abort by default when notification_sound_events is not set', async () => { it('should notify run abort by default when notification_sound_events is not set', async () => {
mockLoadGlobalConfig.mockReturnValue({ mockLoadConfig.mockReturnValue({
language: 'en', language: 'en',
defaultPiece: 'default', defaultPiece: 'default',
logLevel: 'info', logLevel: 'info',
@ -617,7 +625,7 @@ describe('runAllTasks concurrency', () => {
}); });
it('should not notify run abort when runAbort is explicitly false', async () => { it('should not notify run abort when runAbort is explicitly false', async () => {
mockLoadGlobalConfig.mockReturnValue({ mockLoadConfig.mockReturnValue({
language: 'en', language: 'en',
defaultPiece: 'default', defaultPiece: 'default',
logLevel: 'info', logLevel: 'info',
@ -640,7 +648,7 @@ describe('runAllTasks concurrency', () => {
}); });
it('should notify run abort and rethrow when worker pool throws', async () => { it('should notify run abort and rethrow when worker pool throws', async () => {
mockLoadGlobalConfig.mockReturnValue({ mockLoadConfig.mockReturnValue({
language: 'en', language: 'en',
defaultPiece: 'default', defaultPiece: 'default',
logLevel: 'info', logLevel: 'info',
@ -675,7 +683,7 @@ describe('runAllTasks concurrency', () => {
}; };
beforeEach(() => { beforeEach(() => {
mockLoadGlobalConfig.mockReturnValue({ mockLoadConfig.mockReturnValue({
language: 'en', language: 'en',
defaultPiece: 'default', defaultPiece: 'default',
logLevel: 'info', logLevel: 'info',

View File

@ -32,7 +32,7 @@ vi.mock('../infra/config/index.js', () => ({
listPieces: vi.fn(() => ['default']), listPieces: vi.fn(() => ['default']),
listPieceEntries: vi.fn(() => []), listPieceEntries: vi.fn(() => []),
isPiecePath: vi.fn(() => false), isPiecePath: vi.fn(() => false),
loadGlobalConfig: vi.fn(() => ({})), loadConfig: vi.fn(() => ({ global: {}, project: {} })),
})); }));
vi.mock('../infra/task/index.js', () => ({ vi.mock('../infra/task/index.js', () => ({

View File

@ -5,7 +5,7 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest';
import type { TaskInfo } from '../infra/task/index.js'; import type { TaskInfo } from '../infra/task/index.js';
const { mockResolveTaskExecution, mockExecutePiece, mockLoadPieceByIdentifier, mockLoadGlobalConfig, mockLoadProjectConfig, mockBuildTaskResult, mockPersistTaskResult, mockPostExecutionFlow } = const { mockResolveTaskExecution, mockExecutePiece, mockLoadPieceByIdentifier, mockLoadGlobalConfig, mockLoadProjectConfig, mockBuildTaskResult, mockPersistTaskResult, mockPersistTaskError, mockPostExecutionFlow } =
vi.hoisted(() => ({ vi.hoisted(() => ({
mockResolveTaskExecution: vi.fn(), mockResolveTaskExecution: vi.fn(),
mockExecutePiece: vi.fn(), mockExecutePiece: vi.fn(),
@ -14,6 +14,7 @@ const { mockResolveTaskExecution, mockExecutePiece, mockLoadPieceByIdentifier, m
mockLoadProjectConfig: vi.fn(), mockLoadProjectConfig: vi.fn(),
mockBuildTaskResult: vi.fn(), mockBuildTaskResult: vi.fn(),
mockPersistTaskResult: vi.fn(), mockPersistTaskResult: vi.fn(),
mockPersistTaskError: vi.fn(),
mockPostExecutionFlow: vi.fn(), mockPostExecutionFlow: vi.fn(),
})); }));
@ -28,6 +29,7 @@ vi.mock('../features/tasks/execute/pieceExecution.js', () => ({
vi.mock('../features/tasks/execute/taskResultHandler.js', () => ({ vi.mock('../features/tasks/execute/taskResultHandler.js', () => ({
buildTaskResult: (...args: unknown[]) => mockBuildTaskResult(...args), buildTaskResult: (...args: unknown[]) => mockBuildTaskResult(...args),
persistTaskResult: (...args: unknown[]) => mockPersistTaskResult(...args), persistTaskResult: (...args: unknown[]) => mockPersistTaskResult(...args),
persistTaskError: (...args: unknown[]) => mockPersistTaskError(...args),
})); }));
vi.mock('../features/tasks/execute/postExecution.js', () => ({ vi.mock('../features/tasks/execute/postExecution.js', () => ({
@ -37,8 +39,10 @@ vi.mock('../features/tasks/execute/postExecution.js', () => ({
vi.mock('../infra/config/index.js', () => ({ vi.mock('../infra/config/index.js', () => ({
loadPieceByIdentifier: (...args: unknown[]) => mockLoadPieceByIdentifier(...args), loadPieceByIdentifier: (...args: unknown[]) => mockLoadPieceByIdentifier(...args),
isPiecePath: () => false, isPiecePath: () => false,
loadGlobalConfig: () => mockLoadGlobalConfig(), loadConfig: () => ({
loadProjectConfig: () => mockLoadProjectConfig(), global: mockLoadGlobalConfig(),
project: mockLoadProjectConfig(),
}),
})); }));
vi.mock('../shared/ui/index.js', () => ({ vi.mock('../shared/ui/index.js', () => ({
@ -88,10 +92,16 @@ describe('executeAndCompleteTask', () => {
provider: 'claude', provider: 'claude',
personaProviders: {}, personaProviders: {},
providerProfiles: {}, providerProfiles: {},
providerOptions: {
claude: { sandbox: { allowUnsandboxedCommands: true } },
},
}); });
mockLoadProjectConfig.mockReturnValue({ mockLoadProjectConfig.mockReturnValue({
provider: 'claude', provider: 'claude',
providerProfiles: {}, providerProfiles: {},
providerOptions: {
opencode: { networkAccess: true },
},
}); });
mockBuildTaskResult.mockReturnValue({ success: true }); mockBuildTaskResult.mockReturnValue({ success: true });
mockResolveTaskExecution.mockResolvedValue({ mockResolveTaskExecution.mockResolvedValue({
@ -130,8 +140,16 @@ describe('executeAndCompleteTask', () => {
const pieceExecutionOptions = mockExecutePiece.mock.calls[0]?.[3] as { const pieceExecutionOptions = mockExecutePiece.mock.calls[0]?.[3] as {
taskDisplayLabel?: string; taskDisplayLabel?: string;
taskPrefix?: string; taskPrefix?: string;
globalProviderOptions?: unknown;
projectProviderOptions?: unknown;
}; };
expect(pieceExecutionOptions?.taskDisplayLabel).toBe(taskDisplayLabel); expect(pieceExecutionOptions?.taskDisplayLabel).toBe(taskDisplayLabel);
expect(pieceExecutionOptions?.taskPrefix).toBe(taskDisplayLabel); expect(pieceExecutionOptions?.taskPrefix).toBe(taskDisplayLabel);
expect(pieceExecutionOptions?.globalProviderOptions).toEqual({
claude: { sandbox: { allowUnsandboxedCommands: true } },
});
expect(pieceExecutionOptions?.projectProviderOptions).toEqual({
opencode: { networkAccess: true },
});
}); });
}); });

View File

@ -4,7 +4,7 @@
import { existsSync, readFileSync } from 'node:fs'; import { existsSync, readFileSync } from 'node:fs';
import { basename, dirname } from 'node:path'; 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 { getProvider, type ProviderType, type ProviderCallOptions } from '../infra/providers/index.js';
import type { AgentResponse, CustomAgentConfig } from '../core/models/index.js'; import type { AgentResponse, CustomAgentConfig } from '../core/models/index.js';
import { createLogger } from '../shared/utils/index.js'; import { createLogger } from '../shared/utils/index.js';
@ -29,12 +29,13 @@ export class AgentRunner {
agentConfig?: CustomAgentConfig, agentConfig?: CustomAgentConfig,
): ProviderType { ): ProviderType {
if (options?.provider) return options.provider; 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 (projectConfig.provider) return projectConfig.provider;
if (options?.stepProvider) return options.stepProvider; if (options?.stepProvider) return options.stepProvider;
if (agentConfig?.provider) return agentConfig.provider; if (agentConfig?.provider) return agentConfig.provider;
try { try {
const globalConfig = loadGlobalConfig(); const globalConfig = config.global;
if (globalConfig.provider) return globalConfig.provider; if (globalConfig.provider) return globalConfig.provider;
} catch (error) { } catch (error) {
log.debug('Global config not available for provider resolution', { 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?.model) return options.model;
if (options?.stepModel) return options.stepModel; if (options?.stepModel) return options.stepModel;
if (agentConfig?.model) return agentConfig.model; if (agentConfig?.model) return agentConfig.model;
if (!options?.cwd) return undefined;
try { try {
const globalConfig = loadGlobalConfig(); const globalConfig = loadConfig(options.cwd).global;
if (globalConfig.model) { if (globalConfig.model) {
const globalProvider = globalConfig.provider ?? 'claude'; const globalProvider = globalConfig.provider ?? 'claude';
if (globalProvider === resolvedProvider) return globalConfig.model; if (globalProvider === resolvedProvider) return globalConfig.model;

View File

@ -1,13 +1,13 @@
/** /**
* CLI subcommand definitions * 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 { clearPersonaSessions, getCurrentPiece } from '../../infra/config/index.js';
import { success } from '../../shared/ui/index.js'; import { success } from '../../shared/ui/index.js';
import { runAllTasks, addTask, watchTasks, listTasks } from '../../features/tasks/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 { previewPrompts } from '../../features/prompt/index.js';
import { showCatalog } from '../../features/catalog/index.js'; import { showCatalog } from '../../features/catalog/index.js';
import { program, resolvedCwd } from './program.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 const reset = program
.command('reset') .command('reset')
.description('Reset settings to defaults'); .description('Reset settings to defaults');

View File

@ -11,7 +11,7 @@ import { resolve } from 'node:path';
import { import {
initGlobalDirs, initGlobalDirs,
initProjectDirs, initProjectDirs,
loadGlobalConfig, loadConfig,
isVerboseMode, isVerboseMode,
} from '../../infra/config/index.js'; } from '../../infra/config/index.js';
import { setQuietMode } from '../../shared/context.js'; import { setQuietMode } from '../../shared/context.js';
@ -69,7 +69,7 @@ export async function runPreActionHook(): Promise<void> {
const verbose = isVerboseMode(resolvedCwd); const verbose = isVerboseMode(resolvedCwd);
initDebugLogger(verbose ? { enabled: true } : undefined, resolvedCwd); initDebugLogger(verbose ? { enabled: true } : undefined, resolvedCwd);
const config = loadGlobalConfig(); const { global: config } = loadConfig(resolvedCwd);
if (verbose) { if (verbose) {
setVerboseConsole(true); setVerboseConsole(true);

View File

@ -23,7 +23,7 @@ import {
dispatchConversationAction, dispatchConversationAction,
type InteractiveModeResult, type InteractiveModeResult,
} from '../../features/interactive/index.js'; } 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 { DEFAULT_PIECE_NAME } from '../../shared/constants.js';
import { program, resolvedCwd, pipelineMode } from './program.js'; import { program, resolvedCwd, pipelineMode } from './program.js';
import { resolveAgentOverrides, parseCreateWorktreeOption, isDirectTask } from './helpers.js'; import { resolveAgentOverrides, parseCreateWorktreeOption, isDirectTask } from './helpers.js';
@ -137,7 +137,7 @@ export async function executeDefaultAction(task?: string): Promise<void> {
} }
// All paths below go through interactive mode // All paths below go through interactive mode
const globalConfig = loadGlobalConfig(); const { global: globalConfig } = loadConfig(resolvedCwd);
const lang = resolveLanguage(globalConfig.language); const lang = resolveLanguage(globalConfig.language);
const pieceId = await determinePiece(resolvedCwd, selectOptions.piece); const pieceId = await determinePiece(resolvedCwd, selectOptions.piece);

View File

@ -53,7 +53,6 @@ export interface NotificationSoundEventsConfig {
/** Global configuration for takt */ /** Global configuration for takt */
export interface GlobalConfig { export interface GlobalConfig {
language: Language; language: Language;
defaultPiece: string;
logLevel: 'debug' | 'info' | 'warn' | 'error'; logLevel: 'debug' | 'info' | 'warn' | 'error';
provider?: 'claude' | 'codex' | 'opencode' | 'mock'; provider?: 'claude' | 'codex' | 'opencode' | 'mock';
model?: string; model?: string;
@ -100,6 +99,8 @@ export interface GlobalConfig {
notificationSoundEvents?: NotificationSoundEventsConfig; notificationSoundEvents?: NotificationSoundEventsConfig;
/** Number of movement previews to inject into interactive mode (0 to disable, max 10) */ /** Number of movement previews to inject into interactive mode (0 to disable, max 10) */
interactivePreviewMovements?: number; interactivePreviewMovements?: number;
/** Verbose output mode */
verbose?: boolean;
/** Number of tasks to run concurrently in takt run (default: 1 = sequential) */ /** Number of tasks to run concurrently in takt run (default: 1 = sequential) */
concurrency: number; concurrency: number;
/** Polling interval in ms for picking up new tasks during takt run (default: 500, range: 100-5000) */ /** Polling interval in ms for picking up new tasks during takt run (default: 500, range: 100-5000) */

View File

@ -405,7 +405,6 @@ export const PieceCategoryConfigSchema = z.record(z.string(), PieceCategoryConfi
/** Global config schema */ /** Global config schema */
export const GlobalConfigSchema = z.object({ export const GlobalConfigSchema = z.object({
language: LanguageSchema.optional().default(DEFAULT_LANGUAGE), language: LanguageSchema.optional().default(DEFAULT_LANGUAGE),
default_piece: z.string().optional().default('default'),
log_level: z.enum(['debug', 'info', 'warn', 'error']).optional().default('info'), log_level: z.enum(['debug', 'info', 'warn', 'error']).optional().default('info'),
provider: z.enum(['claude', 'codex', 'opencode', 'mock']).optional().default('claude'), provider: z.enum(['claude', 'codex', 'opencode', 'mock']).optional().default('claude'),
model: z.string().optional(), model: z.string().optional(),
@ -458,6 +457,8 @@ export const GlobalConfigSchema = z.object({
}).optional(), }).optional(),
/** Number of movement previews to inject into interactive mode (0 to disable, max 10) */ /** 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), 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) */ /** 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), 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) */ /** Polling interval in ms for picking up new tasks during takt run (default: 500, range: 100-5000) */

View File

@ -1,5 +1,6 @@
import { join } from 'node:path'; import { join } from 'node:path';
import type { PieceMovement, PieceState, Language } from '../../models/types.js'; 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 { RunAgentOptions } from '../../../agents/runner.js';
import type { PhaseRunnerContext } from '../phase-runner.js'; import type { PhaseRunnerContext } from '../phase-runner.js';
import type { PieceEngineOptions, PhaseName } from '../types.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 { resolveMovementProviderModel } from '../provider-resolution.js';
import { DEFAULT_PROVIDER_PERMISSION_PROFILES, resolveMovementPermissionMode } from '../permission-profile-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 { export class OptionsBuilder {
constructor( constructor(
private readonly engineOptions: PieceEngineOptions, private readonly engineOptions: PieceEngineOptions,
@ -54,7 +76,11 @@ export class OptionsBuilder {
projectProviderProfiles: this.engineOptions.projectProviderProfiles, projectProviderProfiles: this.engineOptions.projectProviderProfiles,
globalProviderProfiles: this.engineOptions.globalProviderProfiles ?? DEFAULT_PROVIDER_PERMISSION_PROFILES, globalProviderProfiles: this.engineOptions.globalProviderProfiles ?? DEFAULT_PROVIDER_PERMISSION_PROFILES,
}), }),
providerOptions: step.providerOptions, providerOptions: mergeProviderOptions(
this.engineOptions.globalProviderOptions,
this.engineOptions.projectProviderOptions,
step.providerOptions,
),
language: this.getLanguage(), language: this.getLanguage(),
onStream: this.engineOptions.onStream, onStream: this.engineOptions.onStream,
onPermissionRequest: this.engineOptions.onPermissionRequest, onPermissionRequest: this.engineOptions.onPermissionRequest,

View File

@ -8,6 +8,7 @@
import type { PermissionResult, PermissionUpdate } from '@anthropic-ai/claude-agent-sdk'; import type { PermissionResult, PermissionUpdate } from '@anthropic-ai/claude-agent-sdk';
import type { PieceMovement, AgentResponse, PieceState, Language, LoopMonitorConfig } from '../models/types.js'; import type { PieceMovement, AgentResponse, PieceState, Language, LoopMonitorConfig } from '../models/types.js';
import type { ProviderPermissionProfiles } from '../models/provider-profiles.js'; import type { ProviderPermissionProfiles } from '../models/provider-profiles.js';
import type { MovementProviderOptions } from '../models/piece-types.js';
export type ProviderType = 'claude' | 'codex' | 'opencode' | 'mock'; export type ProviderType = 'claude' | 'codex' | 'opencode' | 'mock';
@ -171,7 +172,7 @@ export interface PieceEngineOptions {
onAskUserQuestion?: AskUserQuestionHandler; onAskUserQuestion?: AskUserQuestionHandler;
/** Callback when iteration limit is reached - returns additional iterations or null to stop */ /** Callback when iteration limit is reached - returns additional iterations or null to stop */
onIterationLimit?: IterationLimitCallback; onIterationLimit?: IterationLimitCallback;
/** Bypass all permission checks (sacrifice-my-pc mode) */ /** Bypass all permission checks */
bypassPermissions?: boolean; bypassPermissions?: boolean;
/** Project root directory (where .takt/ lives). */ /** Project root directory (where .takt/ lives). */
projectCwd: string; projectCwd: string;
@ -183,6 +184,10 @@ export interface PieceEngineOptions {
/** Global config provider (used for provider/profile resolution parity with AgentRunner) */ /** Global config provider (used for provider/profile resolution parity with AgentRunner) */
globalProvider?: ProviderType; globalProvider?: ProviderType;
model?: string; model?: string;
/** Project-level provider options */
projectProviderOptions?: MovementProviderOptions;
/** Global-level provider options */
globalProviderOptions?: MovementProviderOptions;
/** Per-persona provider overrides (e.g., { coder: 'codex' }) */ /** Per-persona provider overrides (e.g., { coder: 'codex' }) */
personaProviders?: Record<string, ProviderType>; personaProviders?: Record<string, ProviderType>;
/** Project-level provider permission profiles */ /** Project-level provider permission profiles */

View File

@ -3,7 +3,6 @@
*/ */
export { switchPiece } from './switchPiece.js'; 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 { ejectBuiltin, ejectFacet, parseFacetType, VALID_FACET_TYPES } from './ejectBuiltin.js';
export { resetCategoriesToDefault } from './resetCategories.js'; export { resetCategoriesToDefault } from './resetCategories.js';
export { deploySkill } from './deploySkill.js'; export { deploySkill } from './deploySkill.js';

View File

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

View File

@ -10,7 +10,7 @@
import chalk from 'chalk'; import chalk from 'chalk';
import { import {
loadGlobalConfig, loadConfig,
loadPersonaSessions, loadPersonaSessions,
updatePersonaSession, updatePersonaSession,
loadSessionState, loadSessionState,
@ -58,7 +58,7 @@ export interface SessionContext {
* Initialize provider, session, and language for interactive conversation. * Initialize provider, session, and language for interactive conversation.
*/ */
export function initializeSession(cwd: string, personaName: string): SessionContext { export function initializeSession(cwd: string, personaName: string): SessionContext {
const globalConfig = loadGlobalConfig(); const { global: globalConfig } = loadConfig(cwd);
const lang = resolveLanguage(globalConfig.language); const lang = resolveLanguage(globalConfig.language);
if (!globalConfig.provider) { if (!globalConfig.provider) {
throw new Error('Provider is not configured.'); throw new Error('Provider is not configured.');

View File

@ -22,7 +22,7 @@ import {
import { resolveLanguage } from './interactive.js'; import { resolveLanguage } from './interactive.js';
import { loadTemplate } from '../../shared/prompts/index.js'; import { loadTemplate } from '../../shared/prompts/index.js';
import { getLabelObject } from '../../shared/i18n/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'; import type { InstructModeResult, InstructUIText } from '../tasks/list/instructMode.js';
/** Failure information for a retry task */ /** Failure information for a retry task */
@ -116,7 +116,7 @@ export async function runRetryMode(
cwd: string, cwd: string,
retryContext: RetryContext, retryContext: RetryContext,
): Promise<InstructModeResult> { ): Promise<InstructModeResult> {
const globalConfig = loadGlobalConfig(); const { global: globalConfig } = loadConfig(cwd);
const lang = resolveLanguage(globalConfig.language); const lang = resolveLanguage(globalConfig.language);
if (!globalConfig.provider) { if (!globalConfig.provider) {

View File

@ -21,7 +21,7 @@ import {
} from '../../infra/github/index.js'; } from '../../infra/github/index.js';
import { stageAndCommit, getCurrentBranch } from '../../infra/task/index.js'; import { stageAndCommit, getCurrentBranch } from '../../infra/task/index.js';
import { executeTask, type TaskExecutionOptions, type PipelineExecutionOptions } from '../tasks/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 { info, error, success, status, blankLine } from '../../shared/ui/index.js';
import { createLogger, getErrorMessage } from '../../shared/utils/index.js'; import { createLogger, getErrorMessage } from '../../shared/utils/index.js';
import type { PipelineConfig } from '../../core/models/index.js'; import type { PipelineConfig } from '../../core/models/index.js';
@ -106,7 +106,7 @@ function buildPipelinePrBody(
*/ */
export async function executePipeline(options: PipelineExecutionOptions): Promise<number> { export async function executePipeline(options: PipelineExecutionOptions): Promise<number> {
const { cwd, piece, autoPr, skipGit } = options; const { cwd, piece, autoPr, skipGit } = options;
const globalConfig = loadGlobalConfig(); const { global: globalConfig } = loadConfig(cwd);
const pipelineConfig = globalConfig.pipeline; const pipelineConfig = globalConfig.pipeline;
let issue: GitHubIssue | undefined; let issue: GitHubIssue | undefined;
let task: string; let task: string;

View File

@ -5,7 +5,7 @@
* Useful for debugging and understanding what prompts agents will receive. * 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 { InstructionBuilder } from '../../core/piece/instruction/InstructionBuilder.js';
import { ReportInstructionBuilder } from '../../core/piece/instruction/ReportInstructionBuilder.js'; import { ReportInstructionBuilder } from '../../core/piece/instruction/ReportInstructionBuilder.js';
import { StatusJudgmentBuilder } from '../../core/piece/instruction/StatusJudgmentBuilder.js'; import { StatusJudgmentBuilder } from '../../core/piece/instruction/StatusJudgmentBuilder.js';
@ -29,7 +29,7 @@ export async function previewPrompts(cwd: string, pieceIdentifier?: string): Pro
return; return;
} }
const globalConfig = loadGlobalConfig(); const { global: globalConfig } = loadConfig(cwd);
const language: Language = globalConfig.language ?? 'en'; const language: Language = globalConfig.language ?? 'en';
header(`Prompt Preview: ${config.name}`); header(`Prompt Preview: ${config.name}`);

View File

@ -17,7 +17,7 @@ import {
updatePersonaSession, updatePersonaSession,
loadWorktreeSessions, loadWorktreeSessions,
updateWorktreeSession, updateWorktreeSession,
loadGlobalConfig, loadConfig,
saveSessionState, saveSessionState,
type SessionState, type SessionState,
} from '../../../infra/config/index.js'; } 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 // Load saved agent sessions only on retry; normal runs start with empty sessions
const isWorktree = cwd !== projectCwd; const isWorktree = cwd !== projectCwd;
const globalConfig = loadGlobalConfig(); const { global: globalConfig } = loadConfig(projectCwd);
const shouldNotify = globalConfig.notificationSound !== false; const shouldNotify = globalConfig.notificationSound !== false;
const notificationSoundEvents = globalConfig.notificationSoundEvents; const notificationSoundEvents = globalConfig.notificationSoundEvents;
const shouldNotifyIterationLimit = shouldNotify && notificationSoundEvents?.iterationLimit !== false; const shouldNotifyIterationLimit = shouldNotify && notificationSoundEvents?.iterationLimit !== false;
@ -446,6 +446,8 @@ export async function executePiece(
projectProvider: options.projectProvider, projectProvider: options.projectProvider,
globalProvider: options.globalProvider, globalProvider: options.globalProvider,
model: options.model, model: options.model,
projectProviderOptions: options.projectProviderOptions,
globalProviderOptions: options.globalProviderOptions,
personaProviders: options.personaProviders, personaProviders: options.personaProviders,
projectProviderProfiles: options.projectProviderProfiles, projectProviderProfiles: options.projectProviderProfiles,
globalProviderProfiles: options.globalProviderProfiles, globalProviderProfiles: options.globalProviderProfiles,

View File

@ -5,7 +5,7 @@
* instructBranch (instruct mode from takt list). * 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 { confirm } from '../../../shared/prompt/index.js';
import { autoCommitAndPush } from '../../../infra/task/index.js'; import { autoCommitAndPush } from '../../../infra/task/index.js';
import { info, error, success } from '../../../shared/ui/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. * Resolve auto-PR setting with priority: CLI option > config > prompt.
*/ */
export async function resolveAutoPr(optionAutoPr: boolean | undefined): Promise<boolean> { export async function resolveAutoPr(optionAutoPr: boolean | undefined, cwd: string): Promise<boolean> {
if (typeof optionAutoPr === 'boolean') { if (typeof optionAutoPr === 'boolean') {
return optionAutoPr; return optionAutoPr;
} }
const globalConfig = loadGlobalConfig(); const { global: globalConfig } = loadConfig(cwd);
if (typeof globalConfig.autoPr === 'boolean') { if (typeof globalConfig.autoPr === 'boolean') {
return globalConfig.autoPr; return globalConfig.autoPr;
} }

View File

@ -4,7 +4,7 @@
import * as fs from 'node:fs'; import * as fs from 'node:fs';
import * as path from 'node:path'; 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 { type TaskInfo, createSharedClone, summarizeTaskName, getCurrentBranch } from '../../../infra/task/index.js';
import { withProgress } from '../../../shared/ui/index.js'; import { withProgress } from '../../../shared/ui/index.js';
import { getTaskSlugFromTaskDir } from '../../../shared/utils/taskPaths.js'; import { getTaskSlugFromTaskDir } from '../../../shared/utils/taskPaths.js';
@ -141,7 +141,7 @@ export async function resolveTaskExecution(
if (data.auto_pr !== undefined) { if (data.auto_pr !== undefined) {
autoPr = data.auto_pr; autoPr = data.auto_pr;
} else { } else {
const globalConfig = loadGlobalConfig(); const { global: globalConfig } = loadConfig(defaultCwd);
autoPr = globalConfig.autoPr ?? false; autoPr = globalConfig.autoPr ?? false;
} }

View File

@ -101,7 +101,7 @@ export async function selectAndExecuteTask(
// Ask for PR creation BEFORE execution (only if worktree is enabled) // Ask for PR creation BEFORE execution (only if worktree is enabled)
let shouldCreatePr = false; let shouldCreatePr = false;
if (isWorktree) { if (isWorktree) {
shouldCreatePr = await resolveAutoPr(options?.autoPr); shouldCreatePr = await resolveAutoPr(options?.autoPr, cwd);
} }
log.info('Starting task execution', { piece: pieceIdentifier, worktree: isWorktree, autoPr: shouldCreatePr }); log.info('Starting task execution', { piece: pieceIdentifier, worktree: isWorktree, autoPr: shouldCreatePr });

View File

@ -2,7 +2,7 @@
* Session management helpers for agent execution * 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'; import type { AgentResponse } from '../../../core/models/index.js';
/** /**
@ -15,7 +15,7 @@ export async function withPersonaSession(
fn: (sessionId?: string) => Promise<AgentResponse>, fn: (sessionId?: string) => Promise<AgentResponse>,
provider?: string provider?: string
): Promise<AgentResponse> { ): Promise<AgentResponse> {
const resolvedProvider = provider ?? loadGlobalConfig().provider ?? 'claude'; const resolvedProvider = provider ?? loadConfig(cwd).global.provider ?? 'claude';
const sessions = loadPersonaSessions(cwd, resolvedProvider); const sessions = loadPersonaSessions(cwd, resolvedProvider);
const sessionId = sessions[personaName]; const sessionId = sessions[personaName];

View File

@ -2,7 +2,7 @@
* Task execution logic * 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 { TaskRunner, type TaskInfo } from '../../../infra/task/index.js';
import { import {
header, header,
@ -86,8 +86,9 @@ async function executeTaskWithResult(options: ExecuteTaskOptions): Promise<Piece
movements: pieceConfig.movements.map((s: { name: string }) => s.name), movements: pieceConfig.movements.map((s: { name: string }) => s.name),
}); });
const globalConfig = loadGlobalConfig(); const config = loadConfig(projectCwd);
const projectConfig = loadProjectConfig(projectCwd); const globalConfig = config.global;
const projectConfig = config.project;
return await executePiece(pieceConfig, task, cwd, { return await executePiece(pieceConfig, task, cwd, {
projectCwd, projectCwd,
language: globalConfig.language, language: globalConfig.language,
@ -95,6 +96,8 @@ async function executeTaskWithResult(options: ExecuteTaskOptions): Promise<Piece
projectProvider: projectConfig.provider, projectProvider: projectConfig.provider,
globalProvider: globalConfig.provider, globalProvider: globalConfig.provider,
model: agentOverrides?.model, model: agentOverrides?.model,
projectProviderOptions: projectConfig.providerOptions,
globalProviderOptions: globalConfig.providerOptions,
personaProviders: globalConfig.personaProviders, personaProviders: globalConfig.personaProviders,
projectProviderProfiles: projectConfig.providerProfiles, projectProviderProfiles: projectConfig.providerProfiles,
globalProviderProfiles: globalConfig.providerProfiles, globalProviderProfiles: globalConfig.providerProfiles,
@ -234,7 +237,7 @@ export async function runAllTasks(
options?: TaskExecutionOptions, options?: TaskExecutionOptions,
): Promise<void> { ): Promise<void> {
const taskRunner = new TaskRunner(cwd); const taskRunner = new TaskRunner(cwd);
const globalConfig = loadGlobalConfig(); const { global: globalConfig } = loadConfig(cwd);
const shouldNotifyRunComplete = globalConfig.notificationSound !== false const shouldNotifyRunComplete = globalConfig.notificationSound !== false
&& globalConfig.notificationSoundEvents?.runComplete !== false; && globalConfig.notificationSoundEvents?.runComplete !== false;
const shouldNotifyRunAbort = globalConfig.notificationSound !== false const shouldNotifyRunAbort = globalConfig.notificationSound !== false

View File

@ -4,6 +4,7 @@
import type { Language } from '../../../core/models/index.js'; import type { Language } from '../../../core/models/index.js';
import type { ProviderPermissionProfiles } from '../../../core/models/provider-profiles.js'; import type { ProviderPermissionProfiles } from '../../../core/models/provider-profiles.js';
import type { MovementProviderOptions } from '../../../core/models/piece-types.js';
import type { ProviderType } from '../../../infra/providers/index.js'; import type { ProviderType } from '../../../infra/providers/index.js';
import type { GitHubIssue } from '../../../infra/github/index.js'; import type { GitHubIssue } from '../../../infra/github/index.js';
@ -37,6 +38,10 @@ export interface PieceExecutionOptions {
/** Global config provider */ /** Global config provider */
globalProvider?: ProviderType; globalProvider?: ProviderType;
model?: string; model?: string;
/** Project-level provider options */
projectProviderOptions?: MovementProviderOptions;
/** Global-level provider options */
globalProviderOptions?: MovementProviderOptions;
/** Per-persona provider overrides (e.g., { coder: 'codex' }) */ /** Per-persona provider overrides (e.g., { coder: 'codex' }) */
personaProviders?: Record<string, ProviderType>; personaProviders?: Record<string, ProviderType>;
/** Project-level provider permission profiles */ /** Project-level provider permission profiles */

View File

@ -23,7 +23,7 @@ import {
import { type RunSessionContext, formatRunSessionForPrompt } from '../../interactive/runSessionReader.js'; import { type RunSessionContext, formatRunSessionForPrompt } from '../../interactive/runSessionReader.js';
import { loadTemplate } from '../../../shared/prompts/index.js'; import { loadTemplate } from '../../../shared/prompts/index.js';
import { getLabelObject } from '../../../shared/i18n/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'; export type InstructModeAction = 'execute' | 'save_task' | 'cancel';
@ -109,7 +109,7 @@ export async function runInstructMode(
pieceContext?: PieceContext, pieceContext?: PieceContext,
runSessionContext?: RunSessionContext, runSessionContext?: RunSessionContext,
): Promise<InstructModeResult> { ): Promise<InstructModeResult> {
const globalConfig = loadGlobalConfig(); const { global: globalConfig } = loadConfig(cwd);
const lang = resolveLanguage(globalConfig.language); const lang = resolveLanguage(globalConfig.language);
if (!globalConfig.provider) { if (!globalConfig.provider) {

View File

@ -11,7 +11,7 @@ import {
TaskRunner, TaskRunner,
detectDefaultBranch, detectDefaultBranch,
} from '../../../infra/task/index.js'; } 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 { info, error as logError } from '../../../shared/ui/index.js';
import { createLogger, getErrorMessage } from '../../../shared/utils/index.js'; import { createLogger, getErrorMessage } from '../../../shared/utils/index.js';
import { runInstructMode } from './instructMode.js'; import { runInstructMode } from './instructMode.js';
@ -93,7 +93,7 @@ export async function instructBranch(
return false; return false;
} }
const globalConfig = loadGlobalConfig(); const { global: globalConfig } = loadConfig(projectDir);
const pieceDesc = getPieceDescription(selectedPiece, projectDir, globalConfig.interactivePreviewMovements); const pieceDesc = getPieceDescription(selectedPiece, projectDir, globalConfig.interactivePreviewMovements);
const pieceContext: PieceContext = { const pieceContext: PieceContext = {
name: pieceDesc.name, name: pieceDesc.name,

View File

@ -8,7 +8,7 @@
import * as fs from 'node:fs'; import * as fs from 'node:fs';
import type { TaskListItem } from '../../../infra/task/index.js'; import type { TaskListItem } from '../../../infra/task/index.js';
import { TaskRunner } 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 { selectPiece } from '../../pieceSelection/index.js';
import { selectOption } from '../../../shared/prompt/index.js'; import { selectOption } from '../../../shared/prompt/index.js';
import { info, header, blankLine, status } from '../../../shared/ui/index.js'; import { info, header, blankLine, status } from '../../../shared/ui/index.js';
@ -133,7 +133,7 @@ export async function retryFailedTask(
return false; return false;
} }
const globalConfig = loadGlobalConfig(); const { global: globalConfig } = loadConfig(projectDir);
const pieceConfig = loadPieceByIdentifier(selectedPiece, projectDir); const pieceConfig = loadPieceByIdentifier(selectedPiece, projectDir);
if (!pieceConfig) { if (!pieceConfig) {

View File

@ -30,7 +30,9 @@ export {
} from './infra/config/loaders/index.js'; } from './infra/config/loaders/index.js';
export type { PieceSource, PieceWithSource, PieceDirEntry } from './infra/config/loaders/index.js'; export type { PieceSource, PieceWithSource, PieceDirEntry } from './infra/config/loaders/index.js';
export { export {
loadProjectConfig, loadConfig,
} from './infra/config/loadConfig.js';
export {
saveProjectConfig, saveProjectConfig,
updateProjectConfig, updateProjectConfig,
getCurrentPiece, getCurrentPiece,

View File

@ -141,7 +141,7 @@ export interface ClaudeCallOptions {
onPermissionRequest?: PermissionHandler; onPermissionRequest?: PermissionHandler;
/** Custom handler for AskUserQuestion tool */ /** Custom handler for AskUserQuestion tool */
onAskUserQuestion?: AskUserQuestionHandler; onAskUserQuestion?: AskUserQuestionHandler;
/** Bypass all permission checks (sacrifice-my-pc mode) */ /** Bypass all permission checks */
bypassPermissions?: boolean; bypassPermissions?: boolean;
/** Anthropic API key to inject via env (bypasses CLI auth) */ /** Anthropic API key to inject via env (bypasses CLI auth) */
anthropicApiKey?: string; anthropicApiKey?: string;
@ -172,7 +172,7 @@ export interface ClaudeSpawnOptions {
onPermissionRequest?: PermissionHandler; onPermissionRequest?: PermissionHandler;
/** Custom handler for AskUserQuestion tool */ /** Custom handler for AskUserQuestion tool */
onAskUserQuestion?: AskUserQuestionHandler; onAskUserQuestion?: AskUserQuestionHandler;
/** Bypass all permission checks (sacrifice-my-pc mode) */ /** Bypass all permission checks */
bypassPermissions?: boolean; bypassPermissions?: boolean;
/** Anthropic API key to inject via env (bypasses CLI auth) */ /** Anthropic API key to inject via env (bypasses CLI auth) */
anthropicApiKey?: string; anthropicApiKey?: string;

View File

@ -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<string, unknown>, path: string, value: unknown): void {
const parts = path.split('.');
let current: Record<string, unknown> = 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<string, unknown>;
}
const leaf = parts[parts.length - 1];
if (!leaf) return;
current[leaf] = value;
}
function applyEnvOverrides(target: Record<string, unknown>, 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<string, unknown>): void {
applyEnvOverrides(target, GLOBAL_ENV_SPECS);
}
export function applyProjectConfigEnvOverrides(target: Record<string, unknown>): void {
applyEnvOverrides(target, PROJECT_ENV_SPECS);
}

View File

@ -15,6 +15,7 @@ import { normalizeProviderOptions } from '../loaders/pieceParser.js';
import { getGlobalConfigPath } from '../paths.js'; import { getGlobalConfigPath } from '../paths.js';
import { DEFAULT_LANGUAGE } from '../../../shared/constants.js'; import { DEFAULT_LANGUAGE } from '../../../shared/constants.js';
import { parseProviderModel } from '../../../shared/utils/providerModel.js'; import { parseProviderModel } from '../../../shared/utils/providerModel.js';
import { applyGlobalConfigEnvOverrides, envVarNameFromPath } from '../env/config-env-overrides.js';
/** Claude-specific model aliases that are not valid for other providers */ /** Claude-specific model aliases that are not valid for other providers */
const CLAUDE_MODEL_ALIASES = new Set(['opus', 'sonnet', 'haiku']); const CLAUDE_MODEL_ALIASES = new Set(['opus', 'sonnet', 'haiku']);
@ -107,20 +108,6 @@ function denormalizeProviderProfiles(
}])) as Record<string, { default_permission_mode: string; movement_permission_overrides?: Record<string, string> }>; }])) as Record<string, { default_permission_mode: string; movement_permission_overrides?: Record<string, string> }>;
} }
/** 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. * Manages global configuration loading and caching.
* Singleton use GlobalConfigManager.getInstance(). * Singleton use GlobalConfigManager.getInstance().
@ -154,17 +141,23 @@ export class GlobalConfigManager {
return this.cachedConfig; return this.cachedConfig;
} }
const configPath = getGlobalConfigPath(); const configPath = getGlobalConfigPath();
if (!existsSync(configPath)) {
const defaultConfig = createDefaultGlobalConfig(); const rawConfig: Record<string, unknown> = {};
this.cachedConfig = defaultConfig; if (existsSync(configPath)) {
return defaultConfig; const content = readFileSync(configPath, 'utf-8');
const parsedRaw = parseYaml(content);
if (parsedRaw && typeof parsedRaw === 'object' && !Array.isArray(parsedRaw)) {
Object.assign(rawConfig, parsedRaw as Record<string, unknown>);
} 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); applyGlobalConfigEnvOverrides(rawConfig);
const parsed = GlobalConfigSchema.parse(raw);
const parsed = GlobalConfigSchema.parse(rawConfig);
const config: GlobalConfig = { const config: GlobalConfig = {
language: parsed.language, language: parsed.language,
defaultPiece: parsed.default_piece,
logLevel: parsed.log_level, logLevel: parsed.log_level,
provider: parsed.provider, provider: parsed.provider,
model: parsed.model, model: parsed.model,
@ -204,6 +197,7 @@ export class GlobalConfigManager {
runAbort: parsed.notification_sound_events.run_abort, runAbort: parsed.notification_sound_events.run_abort,
} : undefined, } : undefined,
interactivePreviewMovements: parsed.interactive_preview_movements, interactivePreviewMovements: parsed.interactive_preview_movements,
verbose: parsed.verbose,
concurrency: parsed.concurrency, concurrency: parsed.concurrency,
taskPollIntervalMs: parsed.task_poll_interval_ms, taskPollIntervalMs: parsed.task_poll_interval_ms,
}; };
@ -217,7 +211,6 @@ export class GlobalConfigManager {
const configPath = getGlobalConfigPath(); const configPath = getGlobalConfigPath();
const raw: Record<string, unknown> = { const raw: Record<string, unknown> = {
language: config.language, language: config.language,
default_piece: config.defaultPiece,
log_level: config.logLevel, log_level: config.logLevel,
provider: config.provider, provider: config.provider,
}; };
@ -316,6 +309,9 @@ export class GlobalConfigManager {
if (config.interactivePreviewMovements !== undefined) { if (config.interactivePreviewMovements !== undefined) {
raw.interactive_preview_movements = config.interactivePreviewMovements; raw.interactive_preview_movements = config.interactivePreviewMovements;
} }
if (config.verbose !== undefined) {
raw.verbose = config.verbose;
}
if (config.concurrency !== undefined && config.concurrency > 1) { if (config.concurrency !== undefined && config.concurrency > 1) {
raw.concurrency = config.concurrency; 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) * Priority: TAKT_ANTHROPIC_API_KEY env var > config.yaml > undefined (CLI auth fallback)
*/ */
export function resolveAnthropicApiKey(): string | undefined { 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; if (envKey) return envKey;
try { try {
@ -399,7 +395,7 @@ export function resolveAnthropicApiKey(): string | undefined {
* Priority: TAKT_OPENAI_API_KEY env var > config.yaml > undefined (CLI auth fallback) * Priority: TAKT_OPENAI_API_KEY env var > config.yaml > undefined (CLI auth fallback)
*/ */
export function resolveOpenaiApiKey(): string | undefined { 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; if (envKey) return envKey;
try { try {
@ -415,7 +411,7 @@ export function resolveOpenaiApiKey(): string | undefined {
* Priority: TAKT_CODEX_CLI_PATH env var > config.yaml > undefined (SDK vendored binary fallback) * Priority: TAKT_CODEX_CLI_PATH env var > config.yaml > undefined (SDK vendored binary fallback)
*/ */
export function resolveCodexCliPath(): string | undefined { export function resolveCodexCliPath(): string | undefined {
const envPath = process.env['TAKT_CODEX_CLI_PATH']; const envPath = process.env[envVarNameFromPath('codex_cli_path')];
if (envPath !== undefined) { if (envPath !== undefined) {
return validateCodexCliPath(envPath, 'TAKT_CODEX_CLI_PATH'); 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 * Priority: TAKT_OPENCODE_API_KEY env var > config.yaml > undefined
*/ */
export function resolveOpencodeApiKey(): string | 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; if (envKey) return envKey;
try { try {
@ -447,4 +443,3 @@ export function resolveOpencodeApiKey(): string | undefined {
return undefined; return undefined;
} }
} }

View File

@ -6,3 +6,4 @@ export * from './paths.js';
export * from './loaders/index.js'; export * from './loaders/index.js';
export * from './global/index.js'; export * from './global/index.js';
export * from './project/index.js'; export * from './project/index.js';
export * from './loadConfig.js';

View File

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

View File

@ -119,9 +119,11 @@ export {
updateProjectConfig, updateProjectConfig,
getCurrentPiece, getCurrentPiece,
setCurrentPiece, setCurrentPiece,
isVerboseMode,
type ProjectLocalConfig, type ProjectLocalConfig,
} from './project/projectConfig.js'; } from './project/projectConfig.js';
export {
isVerboseMode,
} from './project/resolvedSettings.js';
// Re-export session storage functions // Re-export session storage functions
export { export {

View File

@ -8,10 +8,11 @@ export {
updateProjectConfig, updateProjectConfig,
getCurrentPiece, getCurrentPiece,
setCurrentPiece, setCurrentPiece,
isVerboseMode,
type PermissionMode,
type ProjectLocalConfig, type ProjectLocalConfig,
} from './projectConfig.js'; } from './projectConfig.js';
export {
isVerboseMode,
} from './resolvedSettings.js';
export { export {
writeFileAtomic, writeFileAtomic,

View File

@ -8,15 +8,16 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import { join, resolve } from 'node:path'; import { join, resolve } from 'node:path';
import { parse, stringify } from 'yaml'; import { parse, stringify } from 'yaml';
import { copyProjectResourcesToDir } from '../../resources/index.js'; 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 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 */ /** Default project configuration */
const DEFAULT_PROJECT_CONFIG: ProjectLocalConfig = { const DEFAULT_PROJECT_CONFIG: ProjectLocalConfig = {
piece: 'default', piece: 'default',
permissionMode: 'default',
}; };
/** /**
@ -63,21 +64,34 @@ function denormalizeProviderProfiles(profiles: ProviderPermissionProfiles | unde
export function loadProjectConfig(projectDir: string): ProjectLocalConfig { export function loadProjectConfig(projectDir: string): ProjectLocalConfig {
const configPath = getConfigPath(projectDir); const configPath = getConfigPath(projectDir);
if (!existsSync(configPath)) { const parsedConfig: Record<string, unknown> = {};
return { ...DEFAULT_PROJECT_CONFIG }; if (existsSync(configPath)) {
try {
const content = readFileSync(configPath, 'utf-8');
const parsed = (parse(content) as Record<string, unknown> | null) ?? {};
Object.assign(parsedConfig, parsed);
} catch {
return { ...DEFAULT_PROJECT_CONFIG };
}
} }
try { applyProjectConfigEnvOverrides(parsedConfig);
const content = readFileSync(configPath, 'utf-8');
const parsed = (parse(content) as ProjectLocalConfig | null) ?? {}; return {
return { ...DEFAULT_PROJECT_CONFIG,
...DEFAULT_PROJECT_CONFIG, ...(parsedConfig as ProjectLocalConfig),
...parsed, providerOptions: normalizeProviderOptions(parsedConfig.provider_options as {
providerProfiles: normalizeProviderProfiles(parsed.provider_profiles as Record<string, { default_permission_mode: unknown; movement_permission_overrides?: Record<string, unknown> }> | undefined), codex?: { network_access?: boolean };
}; opencode?: { network_access?: boolean };
} catch { claude?: {
return { ...DEFAULT_PROJECT_CONFIG }; sandbox?: {
} allow_unsandboxed_commands?: boolean;
excluded_commands?: string[];
};
};
} | undefined),
providerProfiles: normalizeProviderProfiles(parsedConfig.provider_profiles as Record<string, { default_permission_mode: unknown; movement_permission_overrides?: Record<string, unknown> }> | undefined),
};
} }
/** /**
@ -103,6 +117,7 @@ export function saveProjectConfig(projectDir: string, config: ProjectLocalConfig
delete savePayload.provider_profiles; delete savePayload.provider_profiles;
} }
delete savePayload.providerProfiles; delete savePayload.providerProfiles;
delete savePayload.providerOptions;
const content = stringify(savePayload, { indent: 2 }); const content = stringify(savePayload, { indent: 2 });
writeFileSync(configPath, content, 'utf-8'); writeFileSync(configPath, content, 'utf-8');
@ -135,11 +150,3 @@ export function getCurrentPiece(projectDir: string): string {
export function setCurrentPiece(projectDir: string, piece: string): void { export function setCurrentPiece(projectDir: string, piece: string): void {
updateProjectConfig(projectDir, 'piece', piece); updateProjectConfig(projectDir, 'piece', piece);
} }
/**
* Get verbose mode from project config
*/
export function isVerboseMode(projectDir: string): boolean {
const config = loadProjectConfig(projectDir);
return config.verbose === true;
}

View File

@ -0,0 +1,32 @@
import { envVarNameFromPath } from '../env/config-env-overrides.js';
import { loadConfig } from '../loadConfig.js';
function resolveValue<T>(
envValue: T | undefined,
localValue: T | undefined,
globalValue: T | undefined,
defaultValue: T,
): T {
if (envValue !== undefined) return envValue;
if (localValue !== undefined) return localValue;
if (globalValue !== undefined) return globalValue;
return defaultValue;
}
function loadEnvBooleanSetting(configKey: string): boolean | undefined {
const envKey = envVarNameFromPath(configKey);
const raw = process.env[envKey];
if (raw === undefined) return undefined;
const normalized = raw.trim().toLowerCase();
if (normalized === 'true') return true;
if (normalized === 'false') return false;
throw new Error(`${envKey} must be one of: true, false`);
}
export function isVerboseMode(projectDir: string): boolean {
const envValue = loadEnvBooleanSetting('verbose');
const { project, global } = loadConfig(projectDir);
return resolveValue(envValue, project.verbose, global.verbose, false);
}

View File

@ -2,40 +2,25 @@
* Config module type definitions * Config module type definitions
*/ */
import type { PieceCategoryConfigNode } from '../../core/models/schemas.js';
import type { MovementProviderOptions } from '../../core/models/piece-types.js'; import type { MovementProviderOptions } from '../../core/models/piece-types.js';
import type { ProviderPermissionProfiles } from '../../core/models/provider-profiles.js'; import type { ProviderPermissionProfiles } from '../../core/models/provider-profiles.js';
/** 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 */ /** Project configuration stored in .takt/config.yaml */
export interface ProjectLocalConfig { export interface ProjectLocalConfig {
/** Current piece name */ /** Current piece name */
piece?: string; piece?: string;
/** Provider selection for agent runtime */ /** Provider selection for agent runtime */
provider?: 'claude' | 'codex' | 'opencode'; provider?: 'claude' | 'codex' | 'opencode';
/** Permission mode setting */
permissionMode?: PermissionMode;
/** Verbose output mode */ /** Verbose output mode */
verbose?: boolean; verbose?: boolean;
/** Provider-specific options (overrides global, overridden by piece/movement) */ /** Provider-specific options (overrides global, overridden by piece/movement) */
provider_options?: MovementProviderOptions; provider_options?: MovementProviderOptions;
/** Provider-specific options (camelCase alias) */
providerOptions?: MovementProviderOptions;
/** Provider-specific permission profiles (project-level override) */ /** Provider-specific permission profiles (project-level override) */
provider_profiles?: ProviderPermissionProfiles; provider_profiles?: ProviderPermissionProfiles;
/** Provider-specific permission profiles (camelCase alias) */ /** Provider-specific permission profiles (camelCase alias) */
providerProfiles?: ProviderPermissionProfiles; providerProfiles?: ProviderPermissionProfiles;
/** Piece categories (name -> piece list) */
piece_categories?: Record<string, PieceCategoryConfigNode>;
/** Show uncategorized pieces under Others category */
show_others_category?: boolean;
/** Display name for Others category */
others_category_name?: string;
/** Custom settings */ /** Custom settings */
[key: string]: unknown; [key: string]: unknown;
} }

View File

@ -11,7 +11,7 @@ import * as fs from 'node:fs';
import * as path from 'node:path'; import * as path from 'node:path';
import { execFileSync } from 'node:child_process'; import { execFileSync } from 'node:child_process';
import { createLogger, slugify } from '../../shared/utils/index.js'; 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'; import type { WorktreeOptions, WorktreeResult } from './types.js';
export type { WorktreeOptions, WorktreeResult }; export type { WorktreeOptions, WorktreeResult };
@ -36,7 +36,7 @@ export class CloneManager {
* Returns the configured worktree_dir (resolved to absolute), or ../ * Returns the configured worktree_dir (resolved to absolute), or ../
*/ */
private static resolveCloneBaseDir(projectDir: string): string { private static resolveCloneBaseDir(projectDir: string): string {
const globalConfig = loadGlobalConfig(); const { global: globalConfig } = loadConfig(projectDir);
if (globalConfig.worktreeDir) { if (globalConfig.worktreeDir) {
return path.isAbsolute(globalConfig.worktreeDir) return path.isAbsolute(globalConfig.worktreeDir)
? globalConfig.worktreeDir ? globalConfig.worktreeDir

View File

@ -5,7 +5,7 @@
*/ */
import * as wanakana from 'wanakana'; 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 { getProvider, type ProviderType } from '../providers/index.js';
import { createLogger } from '../../shared/utils/index.js'; import { createLogger } from '../../shared/utils/index.js';
import { loadTemplate } from '../../shared/prompts/index.js'; import { loadTemplate } from '../../shared/prompts/index.js';
@ -53,7 +53,7 @@ export class TaskSummarizer {
taskName: string, taskName: string,
options: SummarizeOptions, options: SummarizeOptions,
): Promise<string> { ): Promise<string> {
const globalConfig = loadGlobalConfig(); const { global: globalConfig } = loadConfig(options.cwd);
const useLLM = options.useLLM ?? (globalConfig.branchNameStrategy === 'ai'); const useLLM = options.useLLM ?? (globalConfig.branchNameStrategy === 'ai');
log.info('Summarizing task name', { taskName, useLLM }); log.info('Summarizing task name', { taskName, useLLM });