refactor: config 3層モデル整理 + supervisor ペルソナのファセット分離是正

Config:
- PersistedGlobalConfig → GlobalConfig にリネーム、互換エイリアス削除
- persisted-global-config.ts → config-types.ts にリネーム
- ProjectConfig → GlobalConfig extends Omit<ProjectConfig, ...> の継承構造に整理
- verbose/logLevel/log_level を削除(logging セクションに統一)
- migration 機構(migratedProjectLocalKeys 等)を削除

Supervisor ペルソナ:
- 後方互換コードの検出・その場しのぎの検出・ボーイスカウトルールを除去(review.md ポリシー / architecture.md ナレッジと重複)
- ピース全体の見直しを supervise.md インストラクションに移動

takt-default-team-leader:
- loop_monitor のインライン instruction_template を既存ファイル参照に変更
- implement の「判断できない」ルールを ai_review → plan に修正
This commit is contained in:
nrslib 2026-03-06 01:27:04 +09:00
parent ebbd1a67a9
commit a8223d231d
48 changed files with 362 additions and 1045 deletions

View File

@ -30,7 +30,6 @@ language: en # UI language: en | ja
# pr_body_template: "{report}" # PR body template. Variables: {issue_body}, {report}, {issue} # pr_body_template: "{report}" # PR body template. Variables: {issue_body}, {report}, {issue}
# Output / notifications # Output / notifications
# verbose: false # Shortcut: enable trace/debug and set logging.level=debug
# minimal_output: false # Suppress detailed agent output # minimal_output: false # Suppress detailed agent output
# notification_sound: true # Master switch for sounds # notification_sound: true # Master switch for sounds
# notification_sound_events: # Per-event sound toggle (unset means true) # notification_sound_events: # Per-event sound toggle (unset means true)

View File

@ -1,9 +1,11 @@
Run tests, verify the build, and perform final approval. Run tests, verify the build, and perform final approval.
**Overall piece verification:** **Overall piece verification:**
1. Whether the plan and implementation results are consistent 1. Check all reports in the report directory and verify overall piece consistency
2. Whether findings from each review movement have been addressed - Does implementation match the plan?
3. Whether each task spec requirement has been achieved - Were all review movement findings properly addressed?
- Was the original task objective achieved?
2. Whether each task spec requirement has been achieved
- Extract requirements one by one from the task spec - Extract requirements one by one from the task spec
- For each requirement, identify the implementing code (file:line) - For each requirement, identify the implementing code (file:line)
- Verify the code actually fulfills the requirement (read the file, run the test) - Verify the code actually fulfills the requirement (read the file, run the test)

View File

@ -81,15 +81,7 @@ You are the **human proxy** in the automated piece. Before approval, verify the
| Production ready | No mock/stub/TODO remaining? | | Production ready | No mock/stub/TODO remaining? |
| Operation | Actually works as expected? | | Operation | Actually works as expected? |
### 6. Backward Compatibility Code Detection ### 6. Spec Compliance Final Check
**Backward compatibility code is unnecessary unless explicitly instructed.** REJECT if found:
- Unused re-exports, `_var` renames, `// removed` comments
- Fallbacks, old API maintenance, migration code
- Legacy support kept "just in case"
### 7. Spec Compliance Final Check
**Final verification that changes comply with the project's documented specifications.** **Final verification that changes comply with the project's documented specifications.**
@ -115,66 +107,6 @@ Additions can be reverted, but restoring deleted flows is difficult.
- A "UI fix" task includes structural changes to backend domain models - A "UI fix" task includes structural changes to backend domain models
- A "display change" task rewrites business logic flows - A "display change" task rewrites business logic flows
### 8. Piece Overall Review
**Check all reports in the report directory and verify overall piece consistency.**
Check:
- Does implementation match the plan (00-plan.md)?
- Were all review step issues properly addressed?
- Was the original task objective achieved?
**Piece-wide issues:**
| Issue | Action |
|-------|--------|
| Plan-implementation gap | REJECT - Request plan revision or implementation fix |
| Unaddressed review feedback | REJECT - Point out specific unaddressed items |
| Deviation from original purpose | REJECT - Request return to objective |
| Scope creep | REJECT - Deletions outside task order must be reverted |
### 9. Improvement Suggestion Check
**Check review reports for unaddressed improvement suggestions.**
Check:
- "Improvement Suggestions" section in Architect report
- Warnings and suggestions in AI Reviewer report
- Recommendations in Security report
**If there are unaddressed improvement suggestions:**
- Judge if the improvement should be addressed in this task
- If it should be addressed, **REJECT** and request fix
- If it should be addressed in next task, record as "technical debt" in report
**Judgment criteria:**
| Type of suggestion | Decision |
|--------------------|----------|
| Minor fix in same file | Address now (REJECT) |
| Fixable in seconds to minutes | Address now (REJECT) |
| Redundant code / unnecessary expression removal | Address now (REJECT) |
| Affects other features | Address in next task (record only) |
| External impact (API changes, etc.) | Address in next task (record only) |
| Requires significant refactoring (large scope) | Address in next task (record only) |
### Boy Scout Rule
**"Functionally harmless" is not a free pass.** Classifying a near-zero-cost fix as "non-blocking" or "next task" is a compromise. There is no guarantee it will be addressed in a future task, and it accumulates as technical debt.
**Principle:** If a reviewer found it and it can be fixed in minutes, make the coder fix it now. Do not settle for recording it as a "non-blocking improvement suggestion."
## Workaround Detection
**REJECT** if any of the following remain:
| Pattern | Example |
|---------|---------|
| TODO/FIXME | `// TODO: implement later` |
| Commented out | Code that should be deleted remains |
| Hardcoded | Values that should be config are hardcoded |
| Mock data | Dummy data unusable in production |
| console.log | Forgotten debug output |
| Skipped tests | `@Disabled`, `.skip()` |
## Important ## Important
- **Actually run**: Don't just look at files, execute and verify - **Actually run**: Don't just look at files, execute and verify

View File

@ -15,19 +15,7 @@ loop_monitors:
threshold: 3 threshold: 3
judge: judge:
persona: supervisor persona: supervisor
instruction_template: | instruction_template: loop-monitor-ai-fix
The ai_review ↔ ai_fix loop has repeated {cycle_count} times.
Review the reports from each cycle and determine whether this loop
is healthy (making progress) or unproductive (repeating the same issues).
**Reports to reference:**
- AI Review results: {report:ai-review.md}
**Judgment criteria:**
- Are new issues being found/fixed in each cycle?
- Are the same findings being repeated?
- Are fixes actually being applied?
rules: rules:
- condition: Healthy (making progress) - condition: Healthy (making progress)
next: ai_review next: ai_review
@ -158,7 +146,7 @@ movements:
- condition: No implementation (report only) - condition: No implementation (report only)
next: ai_review next: ai_review
- condition: Cannot proceed, insufficient info - condition: Cannot proceed, insufficient info
next: ai_review next: plan
- condition: User input required - condition: User input required
next: implement next: implement
requires_user_input: true requires_user_input: true
@ -392,6 +380,7 @@ movements:
edit: false edit: false
persona: supervisor persona: supervisor
policy: review policy: review
knowledge: architecture
provider_options: provider_options:
claude: claude:
allowed_tools: allowed_tools:

View File

@ -30,7 +30,6 @@ language: ja # 表示言語: ja | en
# pr_body_template: "{report}" # PR本文テンプレート。変数: {issue_body}, {report}, {issue} # pr_body_template: "{report}" # PR本文テンプレート。変数: {issue_body}, {report}, {issue}
# 出力・通知 # 出力・通知
# verbose: false # ショートカット: trace/debug有効化 + logging.level=debug
# minimal_output: false # エージェント詳細出力を抑制 # minimal_output: false # エージェント詳細出力を抑制
# notification_sound: true # 通知音全体のON/OFF # notification_sound: true # 通知音全体のON/OFF
# notification_sound_events: # イベント別通知音未指定はtrue扱い # notification_sound_events: # イベント別通知音未指定はtrue扱い

View File

@ -1,9 +1,11 @@
テスト実行、ビルド確認、最終承認を行ってください。 テスト実行、ビルド確認、最終承認を行ってください。
**ピース全体の確認:** **ピース全体の確認:**
1. 計画と実装結果が一致しているか 1. レポートディレクトリ内の全レポートを確認し、ピース全体の整合性をチェックする
2. 各レビュームーブメントの指摘が対応されているか - 計画と実装結果が一致しているか
3. タスク指示書の各要件が達成されているか - 各レビュームーブメントの指摘が適切に対応されているか
- タスクの本来の目的が達成されているか
2. タスク指示書の各要件が達成されているか
- タスク指示書から要件を1つずつ抽出する - タスク指示書から要件を1つずつ抽出する
- 各要件について、実装されたコード(ファイル:行)を特定する - 各要件について、実装されたコード(ファイル:行)を特定する
- コードが要件を満たしていることを実際に確認する(ファイルを読む、テストを実行する) - コードが要件を満たしていることを実際に確認する(ファイルを読む、テストを実行する)

View File

@ -79,31 +79,6 @@
| 本番 Ready | モック・スタブ・TODO が残っていないか | | 本番 Ready | モック・スタブ・TODO が残っていないか |
| 動作 | 実際に期待通り動くか | | 動作 | 実際に期待通り動くか |
### 後方互換コードの検出
明示的な指示がない限り、後方互換コードは不要。以下を見つけたら REJECT。
- 未使用の re-export、`_var` リネーム、`// removed` コメント
- フォールバック、古い API 維持、移行期コード
- 「念のため」残されたレガシー対応
### その場しのぎの検出
以下が残っていたら REJECT。
| パターン | 例 |
|---------|-----|
| TODO/FIXME | `// TODO: implement later` |
| コメントアウト | 消すべきコードが残っている |
| ハードコード | 本来設定値であるべきものが直書き |
| モックデータ | 本番で使えないダミーデータ |
| console.log | デバッグ出力の消し忘れ |
| スキップされたテスト | `@Disabled``.skip()` |
### ボーイスカウトルール
「機能的に無害」は免罪符ではない。修正コストがほぼゼロの指摘を「非ブロッキング」「次回タスク」に分類することは妥協である。レビュアーが発見し、数分以内に修正できる問題は今回のタスクで修正させる。
### スコープクリープの検出(削除は最重要チェック) ### スコープクリープの検出(削除は最重要チェック)
ファイルの**削除**と既存機能の**除去**はスコープクリープの最も危険な形態。 ファイルの**削除**と既存機能の**除去**はスコープクリープの最も危険な形態。
@ -119,10 +94,3 @@
- 「UI修正」タスクでバックエンドのドメインモデルが構造変更されている - 「UI修正」タスクでバックエンドのドメインモデルが構造変更されている
- 「表示変更」タスクでビジネスロジックのフローが書き換えられている - 「表示変更」タスクでビジネスロジックのフローが書き換えられている
### ピース全体の見直し
レポートディレクトリ内の全レポートを確認し、ピース全体の整合性をチェックする。
- 計画と実装結果が一致しているか
- 各レビュームーブメントの指摘が適切に対応されているか
- タスクの本来の目的が達成されているか

View File

@ -15,19 +15,7 @@ loop_monitors:
threshold: 3 threshold: 3
judge: judge:
persona: supervisor persona: supervisor
instruction_template: | instruction_template: loop-monitor-ai-fix
ai_review と ai_fix のループが {cycle_count} 回繰り返されました。
各サイクルのレポートを確認し、このループが健全(進捗がある)か、
非生産的(同じ問題を繰り返している)かを判断してください。
**参照するレポート:**
- AIレビュー結果: {report:ai-review.md}
**判断基準:**
- 各サイクルで新しい問題が発見・修正されているか
- 同じ指摘が繰り返されていないか
- 修正が実際に反映されているか
rules: rules:
- condition: 健全(進捗あり) - condition: 健全(進捗あり)
next: ai_review next: ai_review
@ -158,7 +146,7 @@ movements:
- condition: 実装未着手(レポートのみ) - condition: 実装未着手(レポートのみ)
next: ai_review next: ai_review
- condition: 判断できない、情報不足 - condition: 判断できない、情報不足
next: ai_review next: plan
- condition: ユーザー入力が必要 - condition: ユーザー入力が必要
next: implement next: implement
requires_user_input: true requires_user_input: true
@ -392,6 +380,7 @@ movements:
edit: false edit: false
persona: supervisor persona: supervisor
policy: review policy: review
knowledge: architecture
provider_options: provider_options:
claude: claude:
allowed_tools: allowed_tools:

View File

@ -1,9 +1,9 @@
import { describe, expect, it } from 'vitest'; import { describe, expect, it } from 'vitest';
describe('config API boundary', () => { describe('config API boundary', () => {
it('should expose migrated fallback loader from global config module', async () => { it('should not expose migration-era fallback loader from global config module', async () => {
const globalConfig = await import('../infra/config/global/globalConfig.js'); const globalConfig = await import('../infra/config/global/globalConfig.js');
expect('loadGlobalMigratedProjectLocalFallback' in globalConfig).toBe(true); expect('loadGlobalMigratedProjectLocalFallback' in globalConfig).toBe(false);
}); });
it('should not expose GlobalConfigManager from config public module', async () => { it('should not expose GlobalConfigManager from config public module', async () => {

View File

@ -53,7 +53,6 @@ describe('config env overrides', () => {
it('should apply project env overrides from generated env names', () => { it('should apply project env overrides from generated env names', () => {
process.env.TAKT_MODEL = 'gpt-5'; process.env.TAKT_MODEL = 'gpt-5';
process.env.TAKT_VERBOSE = 'true';
process.env.TAKT_CONCURRENCY = '3'; process.env.TAKT_CONCURRENCY = '3';
process.env.TAKT_ANALYTICS_EVENTS_PATH = '/tmp/project-analytics'; process.env.TAKT_ANALYTICS_EVENTS_PATH = '/tmp/project-analytics';
@ -61,7 +60,6 @@ describe('config env overrides', () => {
applyProjectConfigEnvOverrides(raw); applyProjectConfigEnvOverrides(raw);
expect(raw.model).toBe('gpt-5'); expect(raw.model).toBe('gpt-5');
expect(raw.verbose).toBe(true);
expect(raw.concurrency).toBe(3); expect(raw.concurrency).toBe(3);
expect(raw.analytics).toEqual({ expect(raw.analytics).toEqual({
events_path: '/tmp/project-analytics', events_path: '/tmp/project-analytics',

View File

@ -1,31 +0,0 @@
import { describe, expect, it } from 'vitest';
import type { PersistedGlobalConfig } from '../core/models/persisted-global-config.js';
import type { ProjectLocalConfig } from '../infra/config/types.js';
import type { MigratedProjectLocalConfigKey } from '../infra/config/migratedProjectLocalKeys.js';
import * as migratedProjectLocalKeysModule from '../infra/config/migratedProjectLocalKeys.js';
type Assert<T extends true> = T;
type IsNever<T> = [T] extends [never] ? true : false;
const globalConfigTypeBoundaryGuard: Assert<
IsNever<Extract<keyof PersistedGlobalConfig, MigratedProjectLocalConfigKey>>
> = true;
void globalConfigTypeBoundaryGuard;
const projectConfigTypeBoundaryGuard: Assert<
IsNever<Exclude<MigratedProjectLocalConfigKey, keyof ProjectLocalConfig>>
> = true;
void projectConfigTypeBoundaryGuard;
describe('migrated config key contracts', () => {
it('should expose only runtime exports needed by migrated key metadata module', () => {
expect(Object.keys(migratedProjectLocalKeysModule).sort()).toEqual([
'MIGRATED_PROJECT_LOCAL_CONFIG_KEYS',
'MIGRATED_PROJECT_LOCAL_CONFIG_METADATA',
]);
});
it('should not expose helper exports that bypass metadata contract', () => {
expect('isMigratedProjectLocalConfigKey' in migratedProjectLocalKeysModule).toBe(false);
});
});

View File

@ -906,7 +906,6 @@ describe('analytics config resolution', () => {
describe('isVerboseMode', () => { describe('isVerboseMode', () => {
let testDir: string; let testDir: string;
let originalTaktConfigDir: string | undefined; let originalTaktConfigDir: string | undefined;
let originalTaktVerbose: string | undefined;
let originalTaktLoggingDebug: string | undefined; let originalTaktLoggingDebug: string | undefined;
let originalTaktLoggingTrace: string | undefined; let originalTaktLoggingTrace: string | undefined;
@ -914,11 +913,9 @@ describe('isVerboseMode', () => {
testDir = join(tmpdir(), `takt-test-${randomUUID()}`); testDir = join(tmpdir(), `takt-test-${randomUUID()}`);
mkdirSync(testDir, { recursive: true }); mkdirSync(testDir, { recursive: true });
originalTaktConfigDir = process.env.TAKT_CONFIG_DIR; originalTaktConfigDir = process.env.TAKT_CONFIG_DIR;
originalTaktVerbose = process.env.TAKT_VERBOSE;
originalTaktLoggingDebug = process.env.TAKT_LOGGING_DEBUG; originalTaktLoggingDebug = process.env.TAKT_LOGGING_DEBUG;
originalTaktLoggingTrace = process.env.TAKT_LOGGING_TRACE; originalTaktLoggingTrace = process.env.TAKT_LOGGING_TRACE;
process.env.TAKT_CONFIG_DIR = join(testDir, 'global-takt'); process.env.TAKT_CONFIG_DIR = join(testDir, 'global-takt');
delete process.env.TAKT_VERBOSE;
delete process.env.TAKT_LOGGING_DEBUG; delete process.env.TAKT_LOGGING_DEBUG;
delete process.env.TAKT_LOGGING_TRACE; delete process.env.TAKT_LOGGING_TRACE;
invalidateGlobalConfigCache(); invalidateGlobalConfigCache();
@ -930,11 +927,6 @@ describe('isVerboseMode', () => {
} else { } else {
process.env.TAKT_CONFIG_DIR = originalTaktConfigDir; process.env.TAKT_CONFIG_DIR = originalTaktConfigDir;
} }
if (originalTaktVerbose === undefined) {
delete process.env.TAKT_VERBOSE;
} else {
process.env.TAKT_VERBOSE = originalTaktVerbose;
}
if (originalTaktLoggingDebug === undefined) { if (originalTaktLoggingDebug === undefined) {
delete process.env.TAKT_LOGGING_DEBUG; delete process.env.TAKT_LOGGING_DEBUG;
} else { } else {
@ -951,43 +943,7 @@ describe('isVerboseMode', () => {
} }
}); });
it('should return project verbose when project config has verbose: true', () => { it('should return false when neither project nor global logging.debug is set', () => {
const projectConfigDir = getProjectConfigDir(testDir);
mkdirSync(projectConfigDir, { recursive: true });
writeFileSync(join(projectConfigDir, 'config.yaml'), 'verbose: true\n');
const globalConfigDir = process.env.TAKT_CONFIG_DIR!;
mkdirSync(globalConfigDir, { recursive: true });
writeFileSync(join(globalConfigDir, 'config.yaml'), 'language: en\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'), 'verbose: false\n');
const globalConfigDir = process.env.TAKT_CONFIG_DIR!;
mkdirSync(globalConfigDir, { recursive: true });
writeFileSync(join(globalConfigDir, 'config.yaml'), 'language: en\n');
expect(isVerboseMode(testDir)).toBe(false);
});
it('should use default verbose=false when project verbose is not set', () => {
const projectConfigDir = getProjectConfigDir(testDir);
mkdirSync(projectConfigDir, { recursive: true });
writeFileSync(join(projectConfigDir, 'config.yaml'), '');
const globalConfigDir = process.env.TAKT_CONFIG_DIR!;
mkdirSync(globalConfigDir, { recursive: true });
writeFileSync(join(globalConfigDir, 'config.yaml'), 'language: en\n');
expect(isVerboseMode(testDir)).toBe(false);
});
it('should return false when neither project nor global verbose is set', () => {
expect(isVerboseMode(testDir)).toBe(false); expect(isVerboseMode(testDir)).toBe(false);
}); });
@ -1051,28 +1007,10 @@ describe('isVerboseMode', () => {
expect(isVerboseMode(testDir)).toBe(true); expect(isVerboseMode(testDir)).toBe(true);
}); });
it('should prioritize TAKT_VERBOSE over project and global config', () => { it('should return true when TAKT_LOGGING_DEBUG=true overrides config', () => {
const projectConfigDir = getProjectConfigDir(testDir); process.env.TAKT_LOGGING_DEBUG = 'true';
mkdirSync(projectConfigDir, { recursive: true });
writeFileSync(join(projectConfigDir, 'config.yaml'), 'verbose: false\n');
const globalConfigDir = process.env.TAKT_CONFIG_DIR!;
mkdirSync(globalConfigDir, { recursive: true });
writeFileSync(join(globalConfigDir, 'config.yaml'), 'language: en\n');
process.env.TAKT_VERBOSE = 'true';
expect(isVerboseMode(testDir)).toBe(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', () => {

View File

@ -24,7 +24,6 @@ const {
loadGlobalConfig, loadGlobalConfig,
saveGlobalConfig, saveGlobalConfig,
invalidateGlobalConfigCache, invalidateGlobalConfigCache,
loadGlobalMigratedProjectLocalFallback,
} = await import('../infra/config/global/globalConfig.js'); } = await import('../infra/config/global/globalConfig.js');
const { getGlobalConfigPath } = await import('../infra/config/paths.js'); const { getGlobalConfigPath } = await import('../infra/config/paths.js');
@ -48,28 +47,25 @@ describe('loadGlobalConfig', () => {
expect(config.model).toBeUndefined(); expect(config.model).toBeUndefined();
}); });
it('should not expose migrated project-local fields from global config', () => { it('should not have project-local fields set by default', () => {
const config = loadGlobalConfig() as Record<string, unknown>; const config = loadGlobalConfig();
expect(config).not.toHaveProperty('logLevel'); expect(config.pipeline).toBeUndefined();
expect(config).not.toHaveProperty('pipeline'); expect(config.personaProviders).toBeUndefined();
expect(config).not.toHaveProperty('personaProviders'); expect(config.branchNameStrategy).toBeUndefined();
expect(config).not.toHaveProperty('branchNameStrategy'); expect(config.minimalOutput).toBeUndefined();
expect(config).not.toHaveProperty('minimalOutput'); expect(config.concurrency).toBeUndefined();
expect(config).not.toHaveProperty('concurrency'); expect(config.taskPollIntervalMs).toBeUndefined();
expect(config).not.toHaveProperty('taskPollIntervalMs'); expect(config.interactivePreviewMovements).toBeUndefined();
expect(config).not.toHaveProperty('interactivePreviewMovements');
expect(config).not.toHaveProperty('verbose');
}); });
it('should accept migrated project-local keys in global config.yaml for resolver fallback', () => { it('should accept project-local keys in global config.yaml', () => {
const taktDir = join(testHomeDir, '.takt'); const taktDir = join(testHomeDir, '.takt');
mkdirSync(taktDir, { recursive: true }); mkdirSync(taktDir, { recursive: true });
writeFileSync( writeFileSync(
getGlobalConfigPath(), getGlobalConfigPath(),
[ [
'language: en', 'language: en',
'log_level: debug',
'pipeline:', 'pipeline:',
' default_branch_prefix: "global/"', ' default_branch_prefix: "global/"',
'persona_providers:', 'persona_providers:',
@ -80,31 +76,27 @@ describe('loadGlobalConfig', () => {
'concurrency: 3', 'concurrency: 3',
'task_poll_interval_ms: 1000', 'task_poll_interval_ms: 1000',
'interactive_preview_movements: 2', 'interactive_preview_movements: 2',
'verbose: true',
].join('\n'), ].join('\n'),
'utf-8', 'utf-8',
); );
expect(() => loadGlobalConfig()).not.toThrow(); expect(() => loadGlobalConfig()).not.toThrow();
const config = loadGlobalConfig() as Record<string, unknown>; const config = loadGlobalConfig();
expect(config).not.toHaveProperty('logLevel'); expect(config.pipeline).toEqual({ defaultBranchPrefix: 'global/' });
expect(config).not.toHaveProperty('pipeline'); expect(config.personaProviders).toEqual({ coder: { provider: 'codex' } });
expect(config).not.toHaveProperty('personaProviders'); expect(config.branchNameStrategy).toBe('ai');
expect(config).not.toHaveProperty('branchNameStrategy'); expect(config.minimalOutput).toBe(true);
expect(config).not.toHaveProperty('minimalOutput'); expect(config.concurrency).toBe(3);
expect(config).not.toHaveProperty('concurrency'); expect(config.taskPollIntervalMs).toBe(1000);
expect(config).not.toHaveProperty('taskPollIntervalMs'); expect(config.interactivePreviewMovements).toBe(2);
expect(config).not.toHaveProperty('interactivePreviewMovements');
expect(config).not.toHaveProperty('verbose');
}); });
it('should not persist migrated project-local keys when saving global config', () => { it('should persist project-local keys when saving global config', () => {
const taktDir = join(testHomeDir, '.takt'); const taktDir = join(testHomeDir, '.takt');
mkdirSync(taktDir, { recursive: true }); mkdirSync(taktDir, { recursive: true });
writeFileSync(getGlobalConfigPath(), 'language: en\n', 'utf-8'); writeFileSync(getGlobalConfigPath(), 'language: en\n', 'utf-8');
const config = loadGlobalConfig() as Record<string, unknown>; const config = loadGlobalConfig();
config.logLevel = 'debug';
config.pipeline = { defaultBranchPrefix: 'global/' }; config.pipeline = { defaultBranchPrefix: 'global/' };
config.personaProviders = { coder: { provider: 'codex' } }; config.personaProviders = { coder: { provider: 'codex' } };
config.branchNameStrategy = 'ai'; config.branchNameStrategy = 'ai';
@ -112,19 +104,16 @@ describe('loadGlobalConfig', () => {
config.concurrency = 4; config.concurrency = 4;
config.taskPollIntervalMs = 1200; config.taskPollIntervalMs = 1200;
config.interactivePreviewMovements = 1; config.interactivePreviewMovements = 1;
config.verbose = true; saveGlobalConfig(config);
saveGlobalConfig(config as Parameters<typeof saveGlobalConfig>[0]);
const raw = readFileSync(getGlobalConfigPath(), 'utf-8'); const raw = readFileSync(getGlobalConfigPath(), 'utf-8');
expect(raw).not.toContain('log_level:'); expect(raw).toContain('pipeline:');
expect(raw).not.toContain('pipeline:'); expect(raw).toContain('persona_providers:');
expect(raw).not.toContain('persona_providers:'); expect(raw).toContain('branch_name_strategy:');
expect(raw).not.toContain('branch_name_strategy:'); expect(raw).toContain('minimal_output:');
expect(raw).not.toContain('minimal_output:'); expect(raw).toContain('concurrency:');
expect(raw).not.toContain('concurrency:'); expect(raw).toContain('task_poll_interval_ms:');
expect(raw).not.toContain('task_poll_interval_ms:'); expect(raw).toContain('interactive_preview_movements:');
expect(raw).not.toContain('interactive_preview_movements:');
expect(raw).not.toContain('verbose:');
}); });
it('should return the same cached object on subsequent calls', () => { it('should return the same cached object on subsequent calls', () => {
@ -264,7 +253,7 @@ describe('loadGlobalConfig', () => {
} }
}); });
it('should accept pipeline in global config for migrated fallback', () => { it('should accept pipeline in global config', () => {
const taktDir = join(testHomeDir, '.takt'); const taktDir = join(testHomeDir, '.takt');
mkdirSync(taktDir, { recursive: true }); mkdirSync(taktDir, { recursive: true });
writeFileSync( writeFileSync(
@ -279,18 +268,20 @@ describe('loadGlobalConfig', () => {
); );
expect(() => loadGlobalConfig()).not.toThrow(); expect(() => loadGlobalConfig()).not.toThrow();
const config = loadGlobalConfig() as Record<string, unknown>; const config = loadGlobalConfig();
expect(config).not.toHaveProperty('pipeline'); expect(config.pipeline).toEqual({
defaultBranchPrefix: 'feat/',
commitMessageTemplate: 'fix: {title} (#{issue})',
});
}); });
it('should save and reload pipeline config', () => { it('should save and reload pipeline config', () => {
const taktDir = join(testHomeDir, '.takt'); const taktDir = join(testHomeDir, '.takt');
mkdirSync(taktDir, { recursive: true }); mkdirSync(taktDir, { recursive: true });
// Create minimal config first
writeFileSync(getGlobalConfigPath(), 'language: en\n', 'utf-8'); writeFileSync(getGlobalConfigPath(), 'language: en\n', 'utf-8');
const config = loadGlobalConfig(); const config = loadGlobalConfig();
(config as Record<string, unknown>).pipeline = { config.pipeline = {
defaultBranchPrefix: 'takt/', defaultBranchPrefix: 'takt/',
commitMessageTemplate: 'feat: {title} (#{issue})', commitMessageTemplate: 'feat: {title} (#{issue})',
}; };
@ -298,7 +289,10 @@ describe('loadGlobalConfig', () => {
invalidateGlobalConfigCache(); invalidateGlobalConfigCache();
const reloaded = loadGlobalConfig(); const reloaded = loadGlobalConfig();
expect((reloaded as Record<string, unknown>).pipeline).toBeUndefined(); expect(reloaded.pipeline).toEqual({
defaultBranchPrefix: 'takt/',
commitMessageTemplate: 'feat: {title} (#{issue})',
});
}); });
it('should load auto_pr config from config.yaml', () => { it('should load auto_pr config from config.yaml', () => {
@ -631,7 +625,7 @@ describe('loadGlobalConfig', () => {
}); });
}); });
it('should accept interactive_preview_movements in global config for migrated fallback', () => { it('should accept interactive_preview_movements in global config', () => {
const taktDir = join(testHomeDir, '.takt'); const taktDir = join(testHomeDir, '.takt');
mkdirSync(taktDir, { recursive: true }); mkdirSync(taktDir, { recursive: true });
writeFileSync( writeFileSync(
@ -641,8 +635,8 @@ describe('loadGlobalConfig', () => {
); );
expect(() => loadGlobalConfig()).not.toThrow(); expect(() => loadGlobalConfig()).not.toThrow();
const config = loadGlobalConfig() as Record<string, unknown>; const config = loadGlobalConfig();
expect(config).not.toHaveProperty('interactivePreviewMovements'); expect(config.interactivePreviewMovements).toBe(5);
}); });
it('should save and reload interactive_preview_movements config', () => { it('should save and reload interactive_preview_movements config', () => {
@ -651,24 +645,24 @@ describe('loadGlobalConfig', () => {
writeFileSync(getGlobalConfigPath(), 'language: en\n', 'utf-8'); writeFileSync(getGlobalConfigPath(), 'language: en\n', 'utf-8');
const config = loadGlobalConfig(); const config = loadGlobalConfig();
(config as Record<string, unknown>).interactivePreviewMovements = 7; config.interactivePreviewMovements = 7;
saveGlobalConfig(config); saveGlobalConfig(config);
invalidateGlobalConfigCache(); invalidateGlobalConfigCache();
const reloaded = loadGlobalConfig(); const reloaded = loadGlobalConfig();
expect((reloaded as Record<string, unknown>).interactivePreviewMovements).toBeUndefined(); expect(reloaded.interactivePreviewMovements).toBe(7);
}); });
it('should default interactive_preview_movements to 3', () => { it('should default interactive_preview_movements to undefined', () => {
const taktDir = join(testHomeDir, '.takt'); const taktDir = join(testHomeDir, '.takt');
mkdirSync(taktDir, { recursive: true }); mkdirSync(taktDir, { recursive: true });
writeFileSync(getGlobalConfigPath(), 'language: en\n', 'utf-8'); writeFileSync(getGlobalConfigPath(), 'language: en\n', 'utf-8');
const config = loadGlobalConfig(); const config = loadGlobalConfig();
expect((config as Record<string, unknown>).interactivePreviewMovements).toBeUndefined(); expect(config.interactivePreviewMovements).toBeUndefined();
}); });
it('should accept interactive_preview_movements=0 in global config for migrated fallback', () => { it('should accept interactive_preview_movements=0 in global config', () => {
const taktDir = join(testHomeDir, '.takt'); const taktDir = join(testHomeDir, '.takt');
mkdirSync(taktDir, { recursive: true }); mkdirSync(taktDir, { recursive: true });
writeFileSync( writeFileSync(
@ -678,8 +672,8 @@ describe('loadGlobalConfig', () => {
); );
expect(() => loadGlobalConfig()).not.toThrow(); expect(() => loadGlobalConfig()).not.toThrow();
const config = loadGlobalConfig() as Record<string, unknown>; const config = loadGlobalConfig();
expect(config).not.toHaveProperty('interactivePreviewMovements'); expect(config.interactivePreviewMovements).toBe(0);
}); });
describe('persona_providers', () => { describe('persona_providers', () => {

View File

@ -9,7 +9,7 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from 'node:fs'; import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from 'node:fs';
import { join } from 'node:path'; import { join } from 'node:path';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import type { PersistedGlobalConfig } from '../core/models/persisted-global-config.js'; import type { GlobalConfig } from '../core/models/config-types.js';
// Mock the getGlobalConfigPath to use a test directory // Mock the getGlobalConfigPath to use a test directory
let testConfigPath: string; let testConfigPath: string;
@ -102,7 +102,7 @@ piece_overrides:
}); });
it('should preserve non-empty quality_gates array', () => { it('should preserve non-empty quality_gates array', () => {
const config: PersistedGlobalConfig = { const config: GlobalConfig = {
pieceOverrides: { pieceOverrides: {
qualityGates: ['Test 1', 'Test 2'], qualityGates: ['Test 1', 'Test 2'],
}, },

View File

@ -10,8 +10,12 @@ const projectDir = join(rootDir, 'project');
vi.mock('../infra/config/global/globalConfig.js', async (importOriginal) => { vi.mock('../infra/config/global/globalConfig.js', async (importOriginal) => {
const original = await importOriginal() as Record<string, unknown>; const original = await importOriginal() as Record<string, unknown>;
const globalMigratedValues = { return {
logLevel: 'info', ...original,
loadGlobalConfig: () => ({
language: 'en',
provider: 'claude',
autoFetch: false,
pipeline: { defaultBranchPrefix: 'global/' }, pipeline: { defaultBranchPrefix: 'global/' },
personaProviders: { coder: { provider: 'claude', model: 'claude-3-5-sonnet-latest' } }, personaProviders: { coder: { provider: 'claude', model: 'claude-3-5-sonnet-latest' } },
branchNameStrategy: 'ai', branchNameStrategy: 'ai',
@ -19,16 +23,7 @@ vi.mock('../infra/config/global/globalConfig.js', async (importOriginal) => {
concurrency: 2, concurrency: 2,
taskPollIntervalMs: 2000, taskPollIntervalMs: 2000,
interactivePreviewMovements: 4, interactivePreviewMovements: 4,
verbose: false,
} as const;
return {
...original,
loadGlobalConfig: () => ({
language: 'en',
provider: 'claude',
autoFetch: false,
}), }),
loadGlobalMigratedProjectLocalFallback: () => globalMigratedValues,
invalidateGlobalConfigCache: () => undefined, invalidateGlobalConfigCache: () => undefined,
}; };
}); });
@ -40,7 +35,7 @@ const {
invalidateGlobalConfigCache, invalidateGlobalConfigCache,
} = await import('../infra/config/index.js'); } = await import('../infra/config/index.js');
describe('IT: migrated config keys should prefer project over global', () => { describe('IT: project-local config keys should prefer project over global', () => {
beforeEach(() => { beforeEach(() => {
mkdirSync(projectDir, { recursive: true }); mkdirSync(projectDir, { recursive: true });
mkdirSync(join(projectDir, '.takt'), { recursive: true }); mkdirSync(join(projectDir, '.takt'), { recursive: true });
@ -48,7 +43,6 @@ describe('IT: migrated config keys should prefer project over global', () => {
writeFileSync( writeFileSync(
join(projectDir, '.takt', 'config.yaml'), join(projectDir, '.takt', 'config.yaml'),
[ [
'log_level: debug',
'pipeline:', 'pipeline:',
' default_branch_prefix: "project/"', ' default_branch_prefix: "project/"',
'persona_providers:', 'persona_providers:',
@ -60,7 +54,6 @@ describe('IT: migrated config keys should prefer project over global', () => {
'concurrency: 5', 'concurrency: 5',
'task_poll_interval_ms: 1300', 'task_poll_interval_ms: 1300',
'interactive_preview_movements: 1', 'interactive_preview_movements: 1',
'verbose: true',
].join('\n'), ].join('\n'),
'utf-8', 'utf-8',
); );
@ -77,9 +70,8 @@ describe('IT: migrated config keys should prefer project over global', () => {
} }
}); });
it('should resolve migrated keys from project config when global has conflicting values', () => { it('should resolve keys from project config when global has conflicting values', () => {
const resolved = resolveConfigValues(projectDir, [ const resolved = resolveConfigValues(projectDir, [
'logLevel',
'pipeline', 'pipeline',
'personaProviders', 'personaProviders',
'branchNameStrategy', 'branchNameStrategy',
@ -87,10 +79,8 @@ describe('IT: migrated config keys should prefer project over global', () => {
'concurrency', 'concurrency',
'taskPollIntervalMs', 'taskPollIntervalMs',
'interactivePreviewMovements', 'interactivePreviewMovements',
'verbose',
]); ]);
expect(resolved.logLevel).toBe('debug');
expect(resolved.pipeline).toEqual({ expect(resolved.pipeline).toEqual({
defaultBranchPrefix: 'project/', defaultBranchPrefix: 'project/',
}); });
@ -102,10 +92,9 @@ describe('IT: migrated config keys should prefer project over global', () => {
expect(resolved.concurrency).toBe(5); expect(resolved.concurrency).toBe(5);
expect(resolved.taskPollIntervalMs).toBe(1300); expect(resolved.taskPollIntervalMs).toBe(1300);
expect(resolved.interactivePreviewMovements).toBe(1); expect(resolved.interactivePreviewMovements).toBe(1);
expect(resolved.verbose).toBe(true);
}); });
it('should resolve migrated keys from global when project config does not set them', () => { it('should resolve keys from global when project config does not set them', () => {
writeFileSync( writeFileSync(
join(projectDir, '.takt', 'config.yaml'), join(projectDir, '.takt', 'config.yaml'),
'', '',
@ -115,7 +104,6 @@ describe('IT: migrated config keys should prefer project over global', () => {
invalidateAllResolvedConfigCache(); invalidateAllResolvedConfigCache();
const resolved = resolveConfigValues(projectDir, [ const resolved = resolveConfigValues(projectDir, [
'logLevel',
'pipeline', 'pipeline',
'personaProviders', 'personaProviders',
'branchNameStrategy', 'branchNameStrategy',
@ -123,10 +111,8 @@ describe('IT: migrated config keys should prefer project over global', () => {
'concurrency', 'concurrency',
'taskPollIntervalMs', 'taskPollIntervalMs',
'interactivePreviewMovements', 'interactivePreviewMovements',
'verbose',
]); ]);
expect(resolved.logLevel).toBe('info');
expect(resolved.pipeline).toEqual({ defaultBranchPrefix: 'global/' }); expect(resolved.pipeline).toEqual({ defaultBranchPrefix: 'global/' });
expect(resolved.personaProviders).toEqual({ expect(resolved.personaProviders).toEqual({
coder: { provider: 'claude', model: 'claude-3-5-sonnet-latest' }, coder: { provider: 'claude', model: 'claude-3-5-sonnet-latest' },
@ -136,10 +122,9 @@ describe('IT: migrated config keys should prefer project over global', () => {
expect(resolved.concurrency).toBe(2); expect(resolved.concurrency).toBe(2);
expect(resolved.taskPollIntervalMs).toBe(2000); expect(resolved.taskPollIntervalMs).toBe(2000);
expect(resolved.interactivePreviewMovements).toBe(4); expect(resolved.interactivePreviewMovements).toBe(4);
expect(resolved.verbose).toBe(false);
}); });
it('should mark migrated key source as global when only global defines the key', () => { it('should mark key source as global when only global defines the key', () => {
writeFileSync( writeFileSync(
join(projectDir, '.takt', 'config.yaml'), join(projectDir, '.takt', 'config.yaml'),
'', '',
@ -148,8 +133,8 @@ describe('IT: migrated config keys should prefer project over global', () => {
invalidateGlobalConfigCache(); invalidateGlobalConfigCache();
invalidateAllResolvedConfigCache(); invalidateAllResolvedConfigCache();
expect(resolveConfigValueWithSource(projectDir, 'logLevel')).toEqual({ expect(resolveConfigValueWithSource(projectDir, 'pipeline')).toEqual({
value: 'info', value: { defaultBranchPrefix: 'global/' },
source: 'global', source: 'global',
}); });
}); });

View File

@ -42,7 +42,6 @@ vi.mock('../shared/utils/index.js', async (importOriginal) => ({
vi.mock('../infra/config/global/globalConfig.js', () => ({ vi.mock('../infra/config/global/globalConfig.js', () => ({
loadGlobalConfig: vi.fn().mockReturnValue({}), loadGlobalConfig: vi.fn().mockReturnValue({}),
loadGlobalMigratedProjectLocalFallback: vi.fn().mockReturnValue({}),
getLanguage: vi.fn().mockReturnValue('en'), getLanguage: vi.fn().mockReturnValue('en'),
getDisabledBuiltins: vi.fn().mockReturnValue([]), getDisabledBuiltins: vi.fn().mockReturnValue([]),
getBuiltinPiecesEnabled: vi.fn().mockReturnValue(true), getBuiltinPiecesEnabled: vi.fn().mockReturnValue(true),

View File

@ -46,7 +46,6 @@ vi.mock('../shared/utils/index.js', async (importOriginal) => ({
vi.mock('../infra/config/global/globalConfig.js', () => ({ vi.mock('../infra/config/global/globalConfig.js', () => ({
loadGlobalConfig: vi.fn().mockReturnValue({}), loadGlobalConfig: vi.fn().mockReturnValue({}),
loadGlobalMigratedProjectLocalFallback: vi.fn().mockReturnValue({}),
getLanguage: vi.fn().mockReturnValue('en'), getLanguage: vi.fn().mockReturnValue('en'),
getBuiltinPiecesEnabled: vi.fn().mockReturnValue(true), getBuiltinPiecesEnabled: vi.fn().mockReturnValue(true),
})); }));

View File

@ -47,7 +47,6 @@ vi.mock('../shared/utils/index.js', async (importOriginal) => ({
vi.mock('../infra/config/global/globalConfig.js', () => ({ vi.mock('../infra/config/global/globalConfig.js', () => ({
loadGlobalConfig: vi.fn().mockReturnValue({}), loadGlobalConfig: vi.fn().mockReturnValue({}),
loadGlobalMigratedProjectLocalFallback: vi.fn().mockReturnValue({}),
getLanguage: vi.fn().mockReturnValue('en'), getLanguage: vi.fn().mockReturnValue('en'),
getDisabledBuiltins: vi.fn().mockReturnValue([]), getDisabledBuiltins: vi.fn().mockReturnValue([]),
getBuiltinPiecesEnabled: vi.fn().mockReturnValue(true), getBuiltinPiecesEnabled: vi.fn().mockReturnValue(true),

View File

@ -16,10 +16,11 @@ describe('Schemas accept opencode provider', () => {
expect(result.provider).toBe('opencode'); expect(result.provider).toBe('opencode');
}); });
it('should reject persona_providers in GlobalConfigSchema', () => { it('should accept persona_providers in GlobalConfigSchema', () => {
expect(() => GlobalConfigSchema.parse({ const result = GlobalConfigSchema.parse({
persona_providers: { coder: { provider: 'opencode' } }, persona_providers: { coder: { provider: 'opencode' } },
})).toThrow(); });
expect(result.persona_providers).toEqual({ coder: { provider: 'opencode' } });
}); });
it('should accept opencode_api_key in GlobalConfigSchema', () => { it('should accept opencode_api_key in GlobalConfigSchema', () => {

View File

@ -149,10 +149,9 @@ piece_overrides:
}); });
describe('migrated project-local fields', () => { describe('migrated project-local fields', () => {
it('should load migrated fields from project config yaml', () => { it('should load project-local fields from project config yaml', () => {
const configPath = join(testDir, '.takt', 'config.yaml'); const configPath = join(testDir, '.takt', 'config.yaml');
const configContent = [ const configContent = [
'log_level: debug',
'pipeline:', 'pipeline:',
' default_branch_prefix: "proj/"', ' default_branch_prefix: "proj/"',
' commit_message_template: "feat: {title} (#{issue})"', ' commit_message_template: "feat: {title} (#{issue})"',
@ -165,12 +164,10 @@ piece_overrides:
'concurrency: 3', 'concurrency: 3',
'task_poll_interval_ms: 1200', 'task_poll_interval_ms: 1200',
'interactive_preview_movements: 2', 'interactive_preview_movements: 2',
'verbose: true',
].join('\n'); ].join('\n');
writeFileSync(configPath, configContent, 'utf-8'); writeFileSync(configPath, configContent, 'utf-8');
const loaded = loadProjectConfig(testDir) as Record<string, unknown>; const loaded = loadProjectConfig(testDir);
expect(loaded.logLevel).toBe('debug');
expect(loaded.pipeline).toEqual({ expect(loaded.pipeline).toEqual({
defaultBranchPrefix: 'proj/', defaultBranchPrefix: 'proj/',
commitMessageTemplate: 'feat: {title} (#{issue})', commitMessageTemplate: 'feat: {title} (#{issue})',
@ -183,12 +180,10 @@ piece_overrides:
expect(loaded.concurrency).toBe(3); expect(loaded.concurrency).toBe(3);
expect(loaded.taskPollIntervalMs).toBe(1200); expect(loaded.taskPollIntervalMs).toBe(1200);
expect(loaded.interactivePreviewMovements).toBe(2); expect(loaded.interactivePreviewMovements).toBe(2);
expect(loaded.verbose).toBe(true);
}); });
it('should save migrated fields as snake_case keys', () => { it('should save project-local fields as snake_case keys', () => {
const config = { const config = {
logLevel: 'warn',
pipeline: { pipeline: {
defaultBranchPrefix: 'task/', defaultBranchPrefix: 'task/',
prBodyTemplate: 'Body {report}', prBodyTemplate: 'Body {report}',
@ -201,13 +196,11 @@ piece_overrides:
concurrency: 4, concurrency: 4,
taskPollIntervalMs: 1500, taskPollIntervalMs: 1500,
interactivePreviewMovements: 1, interactivePreviewMovements: 1,
verbose: false,
} as ProjectLocalConfig; } as ProjectLocalConfig;
saveProjectConfig(testDir, config); saveProjectConfig(testDir, config);
const raw = readFileSync(join(testDir, '.takt', 'config.yaml'), 'utf-8'); const raw = readFileSync(join(testDir, '.takt', 'config.yaml'), 'utf-8');
expect(raw).toContain('log_level: warn');
expect(raw).toContain('pipeline:'); expect(raw).toContain('pipeline:');
expect(raw).toContain('default_branch_prefix: task/'); expect(raw).toContain('default_branch_prefix: task/');
expect(raw).toContain('pr_body_template: Body {report}'); expect(raw).toContain('pr_body_template: Body {report}');
@ -218,7 +211,6 @@ piece_overrides:
expect(raw).toContain('concurrency: 4'); expect(raw).toContain('concurrency: 4');
expect(raw).toContain('task_poll_interval_ms: 1500'); expect(raw).toContain('task_poll_interval_ms: 1500');
expect(raw).toContain('interactive_preview_movements: 1'); expect(raw).toContain('interactive_preview_movements: 1');
expect(raw).not.toContain('verbose: false');
}); });
it('should not persist empty pipeline object on save', () => { it('should not persist empty pipeline object on save', () => {
@ -250,17 +242,15 @@ piece_overrides:
expect(raw).not.toContain('personaProviders:'); expect(raw).not.toContain('personaProviders:');
}); });
it('should not persist schema-injected default values on save', () => { it('should not persist unset values on save', () => {
const loaded = loadProjectConfig(testDir); const loaded = loadProjectConfig(testDir);
saveProjectConfig(testDir, loaded); saveProjectConfig(testDir, loaded);
const raw = readFileSync(join(testDir, '.takt', 'config.yaml'), 'utf-8'); const raw = readFileSync(join(testDir, '.takt', 'config.yaml'), 'utf-8');
expect(raw).not.toContain('log_level: info'); expect(raw).not.toContain('minimal_output:');
expect(raw).not.toContain('minimal_output: false'); expect(raw).not.toContain('concurrency:');
expect(raw).not.toContain('concurrency: 1'); expect(raw).not.toContain('task_poll_interval_ms:');
expect(raw).not.toContain('task_poll_interval_ms: 500'); expect(raw).not.toContain('interactive_preview_movements:');
expect(raw).not.toContain('interactive_preview_movements: 3');
expect(raw).not.toContain('verbose: false');
}); });
it('should fail fast when project config contains global-only cli path keys', () => { it('should fail fast when project config contains global-only cli path keys', () => {

View File

@ -4,7 +4,7 @@
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { applyQualityGateOverrides } from '../infra/config/loaders/qualityGateOverrides.js'; import { applyQualityGateOverrides } from '../infra/config/loaders/qualityGateOverrides.js';
import type { PieceOverrides } from '../core/models/persisted-global-config.js'; import type { PieceOverrides } from '../core/models/config-types.js';
type ApplyOverridesArgs = [ type ApplyOverridesArgs = [
string, string,

View File

@ -36,14 +36,10 @@ describe('resetGlobalConfigToTemplate', () => {
const newConfig = readFileSync(configPath, 'utf-8'); const newConfig = readFileSync(configPath, 'utf-8');
expect(newConfig).toContain('# TAKT グローバル設定サンプル'); expect(newConfig).toContain('# TAKT グローバル設定サンプル');
expect(newConfig).toContain('language: ja'); expect(newConfig).toContain('language: ja');
expect(newConfig).not.toContain('provider:'); // Template should only have 'language' as an active (non-commented) setting
expect(newConfig).not.toContain('runtime:'); const activeLines = newConfig.split('\n').filter(line => !line.startsWith('#') && line.trim() !== '');
expect(newConfig).not.toContain('branch_name_strategy:'); expect(activeLines.length).toBe(1);
expect(newConfig).not.toContain('concurrency:'); expect(activeLines[0]).toMatch(/^language: ja/);
expect(newConfig).not.toContain('minimal_output:');
expect(newConfig).not.toContain('task_poll_interval_ms:');
expect(newConfig).not.toContain('persona_providers:');
expect(newConfig).not.toContain('pipeline:');
}); });
it('should create config from default language template when config does not exist', () => { it('should create config from default language template when config does not exist', () => {
@ -57,9 +53,8 @@ describe('resetGlobalConfigToTemplate', () => {
const newConfig = readFileSync(configPath, 'utf-8'); const newConfig = readFileSync(configPath, 'utf-8');
expect(newConfig).toContain('# TAKT global configuration sample'); expect(newConfig).toContain('# TAKT global configuration sample');
expect(newConfig).toContain('language: en'); expect(newConfig).toContain('language: en');
expect(newConfig).not.toContain('provider:'); const activeLines = newConfig.split('\n').filter(line => !line.startsWith('#') && line.trim() !== '');
expect(newConfig).not.toContain('runtime:'); expect(activeLines.length).toBe(1);
expect(newConfig).not.toContain('branch_name_strategy:'); expect(activeLines[0]).toMatch(/^language: en/);
expect(newConfig).not.toContain('concurrency:');
}); });
}); });

View File

@ -1,22 +0,0 @@
import { afterEach, describe, expect, it, vi } from 'vitest';
describe('resolveConfigValue call-chain contract', () => {
afterEach(() => {
vi.resetModules();
vi.doUnmock('../infra/config/global/globalConfig.js');
vi.doUnmock('../infra/config/project/projectConfig.js');
});
it('should fail fast when migrated fallback loader is missing and migrated key is resolved', async () => {
vi.doMock('../infra/config/project/projectConfig.js', () => ({
loadProjectConfig: () => ({}),
}));
vi.doMock('../infra/config/global/globalConfig.js', () => ({
loadGlobalConfig: () => ({ language: 'en' }),
}));
const { resolveConfigValue } = await import('../infra/config/resolveConfigValue.js');
expect(() => resolveConfigValue('/tmp/takt-project', 'logLevel')).toThrow();
});
});

View File

@ -1,9 +1,8 @@
/** /**
* Tests for RESOLUTION_REGISTRY defaultValue removal. * Tests for config resolution defaults and project-local priority.
* *
* Verifies that piece, verbose, and autoFetch no longer rely on * Verifies that keys with PROJECT_LOCAL_DEFAULTS resolve correctly
* RESOLUTION_REGISTRY defaultValue but instead use schema defaults * and that project config takes priority over global config.
* or other guaranteed sources.
*/ */
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
@ -33,11 +32,9 @@ const {
} = await import('../infra/config/resolveConfigValue.js'); } = await import('../infra/config/resolveConfigValue.js');
const { invalidateGlobalConfigCache } = await import('../infra/config/global/globalConfig.js'); const { invalidateGlobalConfigCache } = await import('../infra/config/global/globalConfig.js');
const { getProjectConfigDir } = await import('../infra/config/paths.js'); const { getProjectConfigDir } = await import('../infra/config/paths.js');
const { MIGRATED_PROJECT_LOCAL_CONFIG_KEYS } = await import('../infra/config/migratedProjectLocalKeys.js');
const { MIGRATED_PROJECT_LOCAL_DEFAULTS } = await import('../infra/config/migratedProjectLocalDefaults.js');
type ConfigParameterKey = import('../infra/config/resolveConfigValue.js').ConfigParameterKey; type ConfigParameterKey = import('../infra/config/resolveConfigValue.js').ConfigParameterKey;
describe('RESOLUTION_REGISTRY defaultValue removal', () => { describe('config resolution defaults and project-local priority', () => {
let projectDir: string; let projectDir: string;
beforeEach(() => { beforeEach(() => {
@ -57,68 +54,8 @@ describe('RESOLUTION_REGISTRY defaultValue removal', () => {
} }
}); });
describe('verbose', () => { describe('project-local priority', () => {
it('should resolve verbose to false via resolver default when not set anywhere', () => {
const value = resolveConfigValue(projectDir, 'verbose');
expect(value).toBe(false);
});
it('should report source as default when verbose comes from resolver default', () => {
const result = resolveConfigValueWithSource(projectDir, 'verbose');
expect(result.value).toBe(false);
expect(result.source).toBe('default');
});
it('should resolve verbose default when project does not set it', () => {
writeFileSync(globalConfigPath, 'language: en\n', 'utf-8');
invalidateGlobalConfigCache();
expect(resolveConfigValueWithSource(projectDir, 'verbose')).toEqual({
value: false,
source: 'default',
});
});
it('should resolve verbose from project config when project sets it', () => {
writeFileSync(globalConfigPath, 'language: en\n', 'utf-8');
invalidateGlobalConfigCache();
const configDir = getProjectConfigDir(projectDir);
mkdirSync(configDir, { recursive: true });
writeFileSync(join(configDir, 'config.yaml'), 'verbose: true\n');
const value = resolveConfigValue(projectDir, 'verbose');
expect(value).toBe(true);
});
});
describe('logLevel migration', () => {
it('should resolve logLevel from global logging.level after migration', () => {
writeFileSync(
globalConfigPath,
[
'language: en',
'logging:',
' level: warn',
].join('\n'),
'utf-8',
);
invalidateGlobalConfigCache();
expect(resolveConfigValueWithSource(projectDir, 'logLevel')).toEqual({
value: 'warn',
source: 'global',
});
});
});
describe('project-local priority for migrated keys', () => {
it.each([ it.each([
{
key: 'logLevel',
projectYaml: 'log_level: debug\n',
expected: 'debug',
},
{ {
key: 'minimalOutput', key: 'minimalOutput',
projectYaml: 'minimal_output: true\n', projectYaml: 'minimal_output: true\n',
@ -144,11 +81,6 @@ describe('RESOLUTION_REGISTRY defaultValue removal', () => {
projectYaml: 'concurrency: 3\n', projectYaml: 'concurrency: 3\n',
expected: 3, expected: 3,
}, },
{
key: 'verbose',
projectYaml: 'verbose: true\n',
expected: true,
},
])('should resolve $key from project config', ({ key, projectYaml, expected }) => { ])('should resolve $key from project config', ({ key, projectYaml, expected }) => {
writeFileSync(globalConfigPath, 'language: en\n', 'utf-8'); writeFileSync(globalConfigPath, 'language: en\n', 'utf-8');
invalidateGlobalConfigCache(); invalidateGlobalConfigCache();
@ -213,68 +145,48 @@ describe('RESOLUTION_REGISTRY defaultValue removal', () => {
}); });
}); });
it('should resolve migrated non-default keys as undefined when project keys are unset', () => { it('should resolve non-default keys as undefined when project keys are unset', () => {
const configDir = getProjectConfigDir(projectDir); const configDir = getProjectConfigDir(projectDir);
mkdirSync(configDir, { recursive: true }); mkdirSync(configDir, { recursive: true });
writeFileSync(join(configDir, 'config.yaml'), 'provider: claude\n', 'utf-8'); writeFileSync(join(configDir, 'config.yaml'), 'provider: claude\n', 'utf-8');
writeFileSync( writeFileSync(globalConfigPath, 'language: en\n', 'utf-8');
globalConfigPath,
['language: en'].join('\n'),
'utf-8',
);
invalidateGlobalConfigCache(); invalidateGlobalConfigCache();
const pipelineResult = resolveConfigValueWithSource(projectDir, 'pipeline' as ConfigParameterKey); const pipelineResult = resolveConfigValueWithSource(projectDir, 'pipeline' as ConfigParameterKey);
const personaResult = resolveConfigValueWithSource(projectDir, 'personaProviders' as ConfigParameterKey); const personaResult = resolveConfigValueWithSource(projectDir, 'personaProviders' as ConfigParameterKey);
const branchStrategyResult = resolveConfigValueWithSource(projectDir, 'branchNameStrategy' as ConfigParameterKey); const branchStrategyResult = resolveConfigValueWithSource(projectDir, 'branchNameStrategy' as ConfigParameterKey);
expect(pipelineResult).toEqual({ expect(pipelineResult).toEqual({ value: undefined, source: 'default' });
value: undefined, expect(personaResult).toEqual({ value: undefined, source: 'default' });
source: 'default', expect(branchStrategyResult).toEqual({ value: undefined, source: 'default' });
});
expect(personaResult).toEqual({
value: undefined,
source: 'default',
});
expect(branchStrategyResult).toEqual({
value: undefined,
source: 'default',
});
}); });
it('should resolve default-backed migrated keys from defaults when project keys are unset', () => { it('should resolve default-backed keys from defaults when unset', () => {
const configDir = getProjectConfigDir(projectDir); const configDir = getProjectConfigDir(projectDir);
mkdirSync(configDir, { recursive: true }); mkdirSync(configDir, { recursive: true });
writeFileSync(join(configDir, 'config.yaml'), 'provider: claude\n', 'utf-8'); writeFileSync(join(configDir, 'config.yaml'), 'provider: claude\n', 'utf-8');
writeFileSync( writeFileSync(globalConfigPath, 'language: en\n', 'utf-8');
globalConfigPath,
['language: en'].join('\n'),
'utf-8',
);
invalidateGlobalConfigCache(); invalidateGlobalConfigCache();
expect(resolveConfigValueWithSource(projectDir, 'logLevel')).toEqual({ value: 'info', source: 'default' });
expect(resolveConfigValueWithSource(projectDir, 'minimalOutput')).toEqual({ value: false, source: 'default' }); expect(resolveConfigValueWithSource(projectDir, 'minimalOutput')).toEqual({ value: false, source: 'default' });
expect(resolveConfigValueWithSource(projectDir, 'concurrency')).toEqual({ value: 1, source: 'default' }); expect(resolveConfigValueWithSource(projectDir, 'concurrency')).toEqual({ value: 1, source: 'default' });
expect(resolveConfigValueWithSource(projectDir, 'taskPollIntervalMs')).toEqual({ value: 500, source: 'default' }); expect(resolveConfigValueWithSource(projectDir, 'taskPollIntervalMs')).toEqual({ value: 500, source: 'default' });
expect(resolveConfigValueWithSource(projectDir, 'interactivePreviewMovements')).toEqual({ value: 3, source: 'default' }); expect(resolveConfigValueWithSource(projectDir, 'interactivePreviewMovements')).toEqual({ value: 3, source: 'default' });
}); });
it('should resolve migrated keys from global legacy fields when project keys are unset', () => { it('should resolve keys from global config when project keys are unset', () => {
writeFileSync( writeFileSync(
globalConfigPath, globalConfigPath,
[ [
'language: en', 'language: en',
'log_level: warn',
'pipeline:', 'pipeline:',
' default_branch_prefix: "legacy/"', ' default_branch_prefix: "global/"',
'persona_providers:', 'persona_providers:',
' coder:', ' coder:',
' provider: codex', ' provider: codex',
' model: gpt-5', ' model: gpt-5',
'branch_name_strategy: ai', 'branch_name_strategy: ai',
'minimal_output: true', 'minimal_output: true',
'verbose: true',
'concurrency: 3', 'concurrency: 3',
'task_poll_interval_ms: 1200', 'task_poll_interval_ms: 1200',
'interactive_preview_movements: 2', 'interactive_preview_movements: 2',
@ -283,9 +195,8 @@ describe('RESOLUTION_REGISTRY defaultValue removal', () => {
); );
invalidateGlobalConfigCache(); invalidateGlobalConfigCache();
expect(resolveConfigValueWithSource(projectDir, 'logLevel')).toEqual({ value: 'warn', source: 'global' });
expect(resolveConfigValueWithSource(projectDir, 'pipeline')).toEqual({ expect(resolveConfigValueWithSource(projectDir, 'pipeline')).toEqual({
value: { defaultBranchPrefix: 'legacy/' }, value: { defaultBranchPrefix: 'global/' },
source: 'global', source: 'global',
}); });
expect(resolveConfigValueWithSource(projectDir, 'personaProviders')).toEqual({ expect(resolveConfigValueWithSource(projectDir, 'personaProviders')).toEqual({
@ -297,7 +208,6 @@ describe('RESOLUTION_REGISTRY defaultValue removal', () => {
source: 'global', source: 'global',
}); });
expect(resolveConfigValueWithSource(projectDir, 'minimalOutput')).toEqual({ value: true, source: 'global' }); expect(resolveConfigValueWithSource(projectDir, 'minimalOutput')).toEqual({ value: true, source: 'global' });
expect(resolveConfigValueWithSource(projectDir, 'verbose')).toEqual({ value: true, source: 'global' });
expect(resolveConfigValueWithSource(projectDir, 'concurrency')).toEqual({ value: 3, source: 'global' }); expect(resolveConfigValueWithSource(projectDir, 'concurrency')).toEqual({ value: 3, source: 'global' });
expect(resolveConfigValueWithSource(projectDir, 'taskPollIntervalMs')).toEqual({ value: 1200, source: 'global' }); expect(resolveConfigValueWithSource(projectDir, 'taskPollIntervalMs')).toEqual({ value: 1200, source: 'global' });
expect(resolveConfigValueWithSource(projectDir, 'interactivePreviewMovements')).toEqual({ expect(resolveConfigValueWithSource(projectDir, 'interactivePreviewMovements')).toEqual({
@ -305,60 +215,6 @@ describe('RESOLUTION_REGISTRY defaultValue removal', () => {
source: 'global', source: 'global',
}); });
}); });
it('should resolve migrated numeric key from default when project key is unset', () => {
writeFileSync(globalConfigPath, 'language: en\n', 'utf-8');
invalidateGlobalConfigCache();
expect(resolveConfigValueWithSource(projectDir, 'concurrency' as ConfigParameterKey)).toEqual({
value: 1,
source: 'default',
});
});
it('should resolve migrated persona_providers key from default when project key is unset', () => {
writeFileSync(
globalConfigPath,
['language: en'].join('\n'),
'utf-8',
);
invalidateGlobalConfigCache();
expect(resolveConfigValueWithSource(projectDir, 'personaProviders' as ConfigParameterKey)).toEqual({
value: undefined,
source: 'default',
});
});
it('should resolve all migrated keys from project or defaults when project config has no migrated keys', () => {
const configDir = getProjectConfigDir(projectDir);
mkdirSync(configDir, { recursive: true });
writeFileSync(join(configDir, 'config.yaml'), 'provider: claude\n', 'utf-8');
writeFileSync(
globalConfigPath,
['language: en'].join('\n'),
'utf-8',
);
invalidateGlobalConfigCache();
const expectedByKey: Partial<Record<ConfigParameterKey, unknown>> = {
logLevel: MIGRATED_PROJECT_LOCAL_DEFAULTS.logLevel,
pipeline: undefined,
personaProviders: undefined,
branchNameStrategy: undefined,
minimalOutput: MIGRATED_PROJECT_LOCAL_DEFAULTS.minimalOutput,
concurrency: MIGRATED_PROJECT_LOCAL_DEFAULTS.concurrency,
taskPollIntervalMs: MIGRATED_PROJECT_LOCAL_DEFAULTS.taskPollIntervalMs,
interactivePreviewMovements: MIGRATED_PROJECT_LOCAL_DEFAULTS.interactivePreviewMovements,
verbose: MIGRATED_PROJECT_LOCAL_DEFAULTS.verbose,
};
for (const key of MIGRATED_PROJECT_LOCAL_CONFIG_KEYS) {
const resolved = resolveConfigValueWithSource(projectDir, key);
expect(resolved.source).toBe('default');
expect(resolved.value).toEqual(expectedByKey[key as ConfigParameterKey]);
}
});
}); });
describe('autoFetch', () => { describe('autoFetch', () => {

View File

@ -71,13 +71,13 @@ 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 = resolveConfigValues(resolvedCwd, ['logLevel', 'minimalOutput']); const config = resolveConfigValues(resolvedCwd, ['logging', 'minimalOutput']);
if (verbose) { if (verbose) {
setVerboseConsole(true); setVerboseConsole(true);
setLogLevel('debug'); setLogLevel('debug');
} else { } else {
setLogLevel(config.logLevel); setLogLevel(config.logging?.level ?? 'info');
} }
const quietMode = rootOpts.quiet === true || config.minimalOutput === true; const quietMode = rootOpts.quiet === true || config.minimalOutput === true;

View File

@ -1,5 +1,10 @@
/** /**
* Configuration types (global and project) * Configuration types (global and project)
*
* 3-layer model:
* ProjectConfig .takt/config.yaml (project-level)
* GlobalConfig ~/.takt/config.yaml (user-level, superset of ProjectConfig)
* LoadedConfig resolved values with NonNullable defaults (defined in resolvedConfig.ts)
*/ */
import type { MovementProviderOptions, PieceRuntimeConfig } from './piece-types.js'; import type { MovementProviderOptions, PieceRuntimeConfig } from './piece-types.js';
@ -91,27 +96,65 @@ export interface NotificationSoundEventsConfig {
runAbort?: boolean; runAbort?: boolean;
} }
/** Persisted global configuration for ~/.takt/config.yaml */
export interface PersistedGlobalConfig {
/** /**
* / * Project-level configuration stored in .takt/config.yaml.
* ProjectConfig
* @globalOnly
*/ */
export interface ProjectConfig {
/** Provider selection for agent runtime */
provider?: 'claude' | 'codex' | 'opencode' | 'cursor' | 'copilot' | 'mock';
/** Model selection for agent runtime */
model?: string;
/** Auto-create PR after worktree execution */
autoPr?: boolean;
/** Create PR as draft */
draftPr?: boolean;
/** Base branch to clone from (overrides global baseBranch) */
baseBranch?: string;
/** Submodule acquisition mode (all or explicit path list) */
submodules?: SubmoduleSelection;
/** Compatibility flag for full submodule acquisition when submodules is unset */
withSubmodules?: boolean;
/** Pipeline execution settings */
pipeline?: PipelineConfig;
/** Per-persona provider/model overrides */
personaProviders?: Record<string, PersonaProviderEntry>;
/** Branch name generation strategy */
branchNameStrategy?: 'romaji' | 'ai';
/** Minimal output mode */
minimalOutput?: boolean;
/** Number of tasks to run concurrently in takt run (1-10) */
concurrency?: number;
/** Polling interval in ms for task pickup */
taskPollIntervalMs?: number;
/** Number of movement previews in interactive mode */
interactivePreviewMovements?: number;
/** Project-level analytics overrides */
analytics?: AnalyticsConfig;
/** Provider-specific options (overrides global, overridden by piece/movement) */
providerOptions?: MovementProviderOptions;
/** Provider-specific permission profiles (project-level override) */
providerProfiles?: ProviderPermissionProfiles;
/** Piece-level overrides (quality_gates, etc.) */
pieceOverrides?: PieceOverrides;
/** Runtime environment configuration (project-level override) */
runtime?: PieceRuntimeConfig;
}
/**
* Global configuration persisted in ~/.takt/config.yaml.
*
* Extends ProjectConfig with global-only fields (API keys, CLI paths, etc.).
* For overlapping keys, ProjectConfig values take priority at runtime
* handled by the resolution layer.
*/
export interface GlobalConfig extends Omit<ProjectConfig, 'submodules' | 'withSubmodules'> {
/** @globalOnly */ /** @globalOnly */
language: Language; language: Language;
provider?: 'claude' | 'codex' | 'opencode' | 'cursor' | 'copilot' | 'mock';
model?: string;
/** @globalOnly */ /** @globalOnly */
logging?: LoggingConfig; logging?: LoggingConfig;
analytics?: AnalyticsConfig;
/** @globalOnly */ /** @globalOnly */
/** Directory for shared clones (worktree_dir in config). If empty, uses ../{clone-name} relative to project */ /** Directory for shared clones (worktree_dir in config). If empty, uses ../{clone-name} relative to project */
worktreeDir?: string; worktreeDir?: string;
/** Auto-create PR after worktree execution (default: prompt in interactive mode) */
autoPr?: boolean;
/** Create PR as draft (default: prompt in interactive mode when autoPr is true) */
draftPr?: boolean;
/** @globalOnly */ /** @globalOnly */
/** List of builtin piece/agent names to exclude from fallback loading */ /** List of builtin piece/agent names to exclude from fallback loading */
disabledBuiltins?: string[]; disabledBuiltins?: string[];
@ -163,12 +206,6 @@ export interface PersistedGlobalConfig {
/** @globalOnly */ /** @globalOnly */
/** Path to piece categories file (default: ~/.takt/preferences/piece-categories.yaml) */ /** Path to piece categories file (default: ~/.takt/preferences/piece-categories.yaml) */
pieceCategoriesFile?: string; pieceCategoriesFile?: string;
/** Global provider-specific options (lowest priority) */
providerOptions?: MovementProviderOptions;
/** Provider-specific permission profiles */
providerProfiles?: ProviderPermissionProfiles;
/** Global runtime environment defaults (can be overridden by piece runtime) */
runtime?: PieceRuntimeConfig;
/** @globalOnly */ /** @globalOnly */
/** Prevent macOS idle sleep during takt execution using caffeinate (default: false) */ /** Prevent macOS idle sleep during takt execution using caffeinate (default: false) */
preventSleep?: boolean; preventSleep?: boolean;
@ -181,45 +218,4 @@ export interface PersistedGlobalConfig {
/** @globalOnly */ /** @globalOnly */
/** Opt-in: fetch remote before cloning to keep clones up-to-date (default: false) */ /** Opt-in: fetch remote before cloning to keep clones up-to-date (default: false) */
autoFetch: boolean; autoFetch: boolean;
/** Base branch to clone from (default: current branch) */
baseBranch?: string;
/** Piece-level overrides (quality_gates, etc.) */
pieceOverrides?: PieceOverrides;
}
/** Project-level configuration */
export interface ProjectConfig {
verbose?: boolean;
provider?: 'claude' | 'codex' | 'opencode' | 'cursor' | 'copilot' | 'mock';
model?: string;
analytics?: AnalyticsConfig;
autoPr?: boolean;
draftPr?: boolean;
providerOptions?: MovementProviderOptions;
/** Provider-specific permission profiles */
providerProfiles?: ProviderPermissionProfiles;
/** Project log level */
logLevel?: 'debug' | 'info' | 'warn' | 'error';
/** Pipeline execution settings */
pipeline?: PipelineConfig;
/** Per-persona provider/model overrides */
personaProviders?: Record<string, PersonaProviderEntry>;
/** Branch name generation strategy */
branchNameStrategy?: 'romaji' | 'ai';
/** Minimal output mode */
minimalOutput?: boolean;
/** Number of tasks to run concurrently in takt run (1-10) */
concurrency?: number;
/** Polling interval in ms for task pickup */
taskPollIntervalMs?: number;
/** Number of movement previews in interactive mode */
interactivePreviewMovements?: number;
/** Base branch to clone from (overrides global baseBranch) */
baseBranch?: string;
/** Piece-level overrides (quality_gates, etc.) */
pieceOverrides?: PieceOverrides;
/** Compatibility flag for full submodule acquisition when submodules is unset */
withSubmodules?: boolean;
/** Submodule acquisition mode (all or explicit path list) */
submodules?: SubmoduleSelection;
} }

View File

@ -502,83 +502,8 @@ export const PieceCategoryConfigNodeSchema: z.ZodType<PieceCategoryConfigNode> =
export const PieceCategoryConfigSchema = z.record(z.string(), PieceCategoryConfigNodeSchema); export const PieceCategoryConfigSchema = z.record(z.string(), PieceCategoryConfigNodeSchema);
/** Global config schema */
export const GlobalConfigSchema = z.object({
language: LanguageSchema.optional().default(DEFAULT_LANGUAGE),
provider: ProviderReferenceSchema.optional().default('claude'),
model: z.string().optional(),
logging: LoggingConfigSchema.optional(),
analytics: AnalyticsConfigSchema.optional(),
/** Directory for shared clones (worktree_dir in config). If empty, uses ../{clone-name} relative to project */
worktree_dir: z.string().optional(),
/** Auto-create PR after worktree execution (default: prompt in interactive mode) */
auto_pr: z.boolean().optional(),
/** Create PR as draft (default: prompt in interactive mode when auto_pr is true) */
draft_pr: z.boolean().optional(),
/** List of builtin piece/agent names to exclude from fallback loading */
disabled_builtins: z.array(z.string()).optional().default([]),
/** Enable builtin pieces from builtins/{lang}/pieces */
enable_builtin_pieces: z.boolean().optional(),
/** Anthropic API key for Claude Code SDK (overridden by TAKT_ANTHROPIC_API_KEY env var) */
anthropic_api_key: z.string().optional(),
/** OpenAI API key for Codex SDK (overridden by TAKT_OPENAI_API_KEY env var) */
openai_api_key: z.string().optional(),
/** Gemini API key (overridden by TAKT_GEMINI_API_KEY env var) */
gemini_api_key: z.string().optional(),
/** Google API key (overridden by TAKT_GOOGLE_API_KEY env var) */
google_api_key: z.string().optional(),
/** Groq API key (overridden by TAKT_GROQ_API_KEY env var) */
groq_api_key: z.string().optional(),
/** OpenRouter API key (overridden by TAKT_OPENROUTER_API_KEY env var) */
openrouter_api_key: z.string().optional(),
/** External Codex CLI path for Codex SDK override (overridden by TAKT_CODEX_CLI_PATH env var) */
codex_cli_path: z.string().optional(),
/** External Claude Code CLI path (overridden by TAKT_CLAUDE_CLI_PATH env var) */
claude_cli_path: z.string().optional(),
/** External cursor-agent CLI path (overridden by TAKT_CURSOR_CLI_PATH env var) */
cursor_cli_path: z.string().optional(),
/** External Copilot CLI path (overridden by TAKT_COPILOT_CLI_PATH env var) */
copilot_cli_path: z.string().optional(),
/** Copilot GitHub token (overridden by TAKT_COPILOT_GITHUB_TOKEN env var) */
copilot_github_token: z.string().optional(),
/** OpenCode API key for OpenCode SDK (overridden by TAKT_OPENCODE_API_KEY env var) */
opencode_api_key: z.string().optional(),
/** Cursor API key for Cursor Agent CLI/API (overridden by TAKT_CURSOR_API_KEY env var) */
cursor_api_key: z.string().optional(),
/** Path to bookmarks file (default: ~/.takt/preferences/bookmarks.yaml) */
bookmarks_file: z.string().optional(),
/** Path to piece categories file (default: ~/.takt/preferences/piece-categories.yaml) */
piece_categories_file: z.string().optional(),
/** Global provider-specific options (lowest priority) */
provider_options: MovementProviderOptionsSchema,
/** Provider-specific permission profiles */
provider_profiles: ProviderPermissionProfilesSchema,
/** Global runtime defaults (piece runtime overrides this) */
runtime: RuntimeConfigSchema,
/** Prevent macOS idle sleep during takt execution using caffeinate (default: false) */
prevent_sleep: z.boolean().optional(),
/** Enable notification sounds (default: true when undefined) */
notification_sound: z.boolean().optional(),
/** Notification sound toggles per event timing */
notification_sound_events: z.object({
iteration_limit: z.boolean().optional(),
piece_complete: z.boolean().optional(),
piece_abort: z.boolean().optional(),
run_complete: z.boolean().optional(),
run_abort: z.boolean().optional(),
}).optional(),
/** Opt-in: fetch remote before cloning to keep clones up-to-date (default: false) */
auto_fetch: z.boolean().optional().default(false),
/** Base branch to clone from (default: current branch) */
base_branch: z.string().optional(),
/** Piece-level overrides (quality_gates, etc.) */
piece_overrides: PieceOverridesSchema,
}).strict();
/** Project config schema */ /** Project config schema */
export const ProjectConfigSchema = z.object({ export const ProjectConfigSchema = z.object({
log_level: z.enum(['debug', 'info', 'warn', 'error']).optional(),
verbose: z.boolean().optional(),
provider: ProviderReferenceSchema.optional(), provider: ProviderReferenceSchema.optional(),
model: z.string().optional(), model: z.string().optional(),
analytics: AnalyticsConfigSchema.optional(), analytics: AnalyticsConfigSchema.optional(),
@ -616,3 +541,71 @@ export const ProjectConfigSchema = z.object({
/** Compatibility flag for full submodule acquisition when submodules is unset */ /** Compatibility flag for full submodule acquisition when submodules is unset */
with_submodules: z.boolean().optional(), with_submodules: z.boolean().optional(),
}).strict(); }).strict();
/** Global-only fields (not in ProjectConfig) */
const GlobalOnlyConfigSchema = z.object({
language: LanguageSchema.optional().default(DEFAULT_LANGUAGE),
logging: LoggingConfigSchema.optional(),
/** Directory for shared clones (worktree_dir in config). If empty, uses ../{clone-name} relative to project */
worktree_dir: z.string().optional(),
/** List of builtin piece/agent names to exclude from fallback loading */
disabled_builtins: z.array(z.string()).optional().default([]),
/** Enable builtin pieces from builtins/{lang}/pieces */
enable_builtin_pieces: z.boolean().optional(),
/** Anthropic API key for Claude Code SDK (overridden by TAKT_ANTHROPIC_API_KEY env var) */
anthropic_api_key: z.string().optional(),
/** OpenAI API key for Codex SDK (overridden by TAKT_OPENAI_API_KEY env var) */
openai_api_key: z.string().optional(),
/** Gemini API key (overridden by TAKT_GEMINI_API_KEY env var) */
gemini_api_key: z.string().optional(),
/** Google API key (overridden by TAKT_GOOGLE_API_KEY env var) */
google_api_key: z.string().optional(),
/** Groq API key (overridden by TAKT_GROQ_API_KEY env var) */
groq_api_key: z.string().optional(),
/** OpenRouter API key (overridden by TAKT_OPENROUTER_API_KEY env var) */
openrouter_api_key: z.string().optional(),
/** External Codex CLI path for Codex SDK override (overridden by TAKT_CODEX_CLI_PATH env var) */
codex_cli_path: z.string().optional(),
/** External Claude Code CLI path (overridden by TAKT_CLAUDE_CLI_PATH env var) */
claude_cli_path: z.string().optional(),
/** External cursor-agent CLI path (overridden by TAKT_CURSOR_CLI_PATH env var) */
cursor_cli_path: z.string().optional(),
/** External Copilot CLI path (overridden by TAKT_COPILOT_CLI_PATH env var) */
copilot_cli_path: z.string().optional(),
/** Copilot GitHub token (overridden by TAKT_COPILOT_GITHUB_TOKEN env var) */
copilot_github_token: z.string().optional(),
/** OpenCode API key for OpenCode SDK (overridden by TAKT_OPENCODE_API_KEY env var) */
opencode_api_key: z.string().optional(),
/** Cursor API key for Cursor Agent CLI/API (overridden by TAKT_CURSOR_API_KEY env var) */
cursor_api_key: z.string().optional(),
/** Path to bookmarks file (default: ~/.takt/preferences/bookmarks.yaml) */
bookmarks_file: z.string().optional(),
/** Path to piece categories file (default: ~/.takt/preferences/piece-categories.yaml) */
piece_categories_file: z.string().optional(),
/** Prevent macOS idle sleep during takt execution using caffeinate (default: false) */
prevent_sleep: z.boolean().optional(),
/** Enable notification sounds (default: true when undefined) */
notification_sound: z.boolean().optional(),
/** Notification sound toggles per event timing */
notification_sound_events: z.object({
iteration_limit: z.boolean().optional(),
piece_complete: z.boolean().optional(),
piece_abort: z.boolean().optional(),
run_complete: z.boolean().optional(),
run_abort: z.boolean().optional(),
}).optional(),
/** Opt-in: fetch remote before cloning to keep clones up-to-date (default: false) */
auto_fetch: z.boolean().optional().default(false),
});
/** Global config schema = ProjectConfig + global-only fields.
* For overlapping keys (provider, model, etc.), GlobalOnly definitions take precedence in the schema.
* Runtime value priority (project > global) is handled by the resolution layer. */
export const GlobalConfigSchema = ProjectConfigSchema
.omit({ submodules: true, with_submodules: true })
.merge(GlobalOnlyConfigSchema)
.extend({
/** Override provider with default value for global config */
provider: ProviderReferenceSchema.optional().default('claude'),
})
.strict();

View File

@ -68,4 +68,4 @@ export type {
Language, Language,
PipelineConfig, PipelineConfig,
ProjectConfig, ProjectConfig,
} from './persisted-global-config.js'; } from './config-types.js';

View File

@ -1,5 +1,5 @@
import type { PieceMovement } from '../models/types.js'; import type { PieceMovement } from '../models/types.js';
import type { PersonaProviderEntry } from '../models/persisted-global-config.js'; import type { PersonaProviderEntry } from '../models/config-types.js';
import type { ProviderType } from './types.js'; import type { ProviderType } from './types.js';
export interface MovementProviderModelInput { export interface MovementProviderModelInput {

View File

@ -7,7 +7,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 { PersonaProviderEntry } from '../models/persisted-global-config.js'; import type { PersonaProviderEntry } from '../models/config-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'; import type { MovementProviderOptions } from '../models/piece-types.js';

View File

@ -3,7 +3,7 @@
*/ */
import type { Language } from '../../../core/models/index.js'; import type { Language } from '../../../core/models/index.js';
import type { PersonaProviderEntry } from '../../../core/models/persisted-global-config.js'; import type { PersonaProviderEntry } from '../../../core/models/config-types.js';
import type { ProviderPermissionProfiles } from '../../../core/models/provider-profiles.js'; import type { ProviderPermissionProfiles } from '../../../core/models/provider-profiles.js';
import type { MovementProviderOptions } from '../../../core/models/piece-types.js'; import type { MovementProviderOptions } from '../../../core/models/piece-types.js';
import type { ProviderType } from '../../../infra/providers/index.js'; import type { ProviderType } from '../../../infra/providers/index.js';

View File

@ -1,6 +1,6 @@
import type { MovementProviderOptions, PieceRuntimeConfig } from '../../core/models/piece-types.js'; import type { MovementProviderOptions, PieceRuntimeConfig } from '../../core/models/piece-types.js';
import type { ProviderPermissionProfiles } from '../../core/models/provider-profiles.js'; import type { ProviderPermissionProfiles } from '../../core/models/provider-profiles.js';
import type { PieceOverrides, PersonaProviderEntry, PipelineConfig } from '../../core/models/persisted-global-config.js'; import type { PieceOverrides, PersonaProviderEntry, PipelineConfig } from '../../core/models/config-types.js';
import { validateProviderModelCompatibility } from './providerModelCompatibility.js'; import { validateProviderModelCompatibility } from './providerModelCompatibility.js';
export function normalizeRuntime( export function normalizeRuntime(

View File

@ -152,10 +152,8 @@ const GLOBAL_ENV_SPECS: readonly EnvSpec[] = [
]; ];
const PROJECT_ENV_SPECS: readonly EnvSpec[] = [ const PROJECT_ENV_SPECS: readonly EnvSpec[] = [
{ path: 'log_level', type: 'string' },
{ path: 'provider', type: 'string' }, { path: 'provider', type: 'string' },
{ path: 'model', type: 'string' }, { path: 'model', type: 'string' },
{ path: 'verbose', type: 'boolean' },
{ path: 'concurrency', type: 'number' }, { path: 'concurrency', type: 'number' },
{ path: 'pipeline', type: 'json' }, { path: 'pipeline', type: 'json' },
{ path: 'pipeline.default_branch_prefix', type: 'string' }, { path: 'pipeline.default_branch_prefix', type: 'string' },

View File

@ -1,28 +1,11 @@
/** /**
* Global configuration public API. * Global configuration public API.
* Keep this file as a stable facade and delegate implementations to focused modules. * Keep this file as a stable facade and delegate implementations to focused modules.
* Global-only field ownership is defined in PersistedGlobalConfig via `@globalOnly` markers.
*/ */
import type { PersistedGlobalConfig } from '../../../core/models/persisted-global-config.js';
import type { MigratedProjectLocalConfigKey } from '../migratedProjectLocalKeys.js';
type Assert<T extends true> = T;
type IsNever<T> = [T] extends [never] ? true : false;
/**
* Compile-time guard:
* migrated project-local fields must not exist on PersistedGlobalConfig.
*/
const globalConfigMigratedFieldGuard: Assert<
IsNever<Extract<keyof PersistedGlobalConfig, MigratedProjectLocalConfigKey>>
> = true;
void globalConfigMigratedFieldGuard;
export { export {
invalidateGlobalConfigCache, invalidateGlobalConfigCache,
loadGlobalConfig, loadGlobalConfig,
loadGlobalMigratedProjectLocalFallback,
saveGlobalConfig, saveGlobalConfig,
validateCliPath, validateCliPath,
} from './globalConfigCore.js'; } from './globalConfigCore.js';

View File

@ -1,7 +1,7 @@
import { readFileSync, existsSync, writeFileSync } from 'node:fs'; import { readFileSync, existsSync, writeFileSync } from 'node:fs';
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'; import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
import { GlobalConfigSchema } from '../../../core/models/index.js'; import { GlobalConfigSchema } from '../../../core/models/index.js';
import type { PersistedGlobalConfig } from '../../../core/models/persisted-global-config.js'; import type { GlobalConfig } from '../../../core/models/config-types.js';
import { import {
normalizeConfigProviderReference, normalizeConfigProviderReference,
type ConfigProviderReference, type ConfigProviderReference,
@ -9,16 +9,14 @@ import {
import { import {
normalizeProviderProfiles, normalizeProviderProfiles,
normalizePieceOverrides, normalizePieceOverrides,
normalizePipelineConfig,
normalizePersonaProviders,
normalizeRuntime,
} from '../configNormalizers.js'; } from '../configNormalizers.js';
import { getGlobalConfigPath } from '../paths.js'; import { getGlobalConfigPath } from '../paths.js';
import { applyGlobalConfigEnvOverrides } from '../env/config-env-overrides.js'; import { applyGlobalConfigEnvOverrides } from '../env/config-env-overrides.js';
import { invalidateAllResolvedConfigCache } from '../resolutionCache.js'; import { invalidateAllResolvedConfigCache } from '../resolutionCache.js';
import { validateProviderModelCompatibility } from '../providerModelCompatibility.js'; import { validateProviderModelCompatibility } from '../providerModelCompatibility.js';
import {
extractMigratedProjectLocalFallback,
removeMigratedProjectLocalKeys,
type GlobalMigratedProjectLocalFallback,
} from './globalMigratedProjectLocalFallback.js';
import { sanitizeConfigValue } from './globalConfigLegacyMigration.js'; import { sanitizeConfigValue } from './globalConfigLegacyMigration.js';
import { serializeGlobalConfig } from './globalConfigSerializer.js'; import { serializeGlobalConfig } from './globalConfigSerializer.js';
export { validateCliPath } from './cliPathValidator.js'; export { validateCliPath } from './cliPathValidator.js';
@ -30,12 +28,11 @@ function getRecord(value: unknown): Record<string, unknown> | undefined {
return value as Record<string, unknown>; return value as Record<string, unknown>;
} }
type ProviderType = NonNullable<PersistedGlobalConfig['provider']>; type ProviderType = NonNullable<GlobalConfig['provider']>;
type RawProviderReference = ConfigProviderReference<ProviderType>; type RawProviderReference = ConfigProviderReference<ProviderType>;
export class GlobalConfigManager { export class GlobalConfigManager {
private static instance: GlobalConfigManager | null = null; private static instance: GlobalConfigManager | null = null;
private cachedConfig: PersistedGlobalConfig | null = null; private cachedConfig: GlobalConfig | null = null;
private cachedMigratedProjectLocalFallback: GlobalMigratedProjectLocalFallback | null = null;
private constructor() {} private constructor() {}
static getInstance(): GlobalConfigManager { static getInstance(): GlobalConfigManager {
@ -51,10 +48,9 @@ export class GlobalConfigManager {
invalidateCache(): void { invalidateCache(): void {
this.cachedConfig = null; this.cachedConfig = null;
this.cachedMigratedProjectLocalFallback = null;
} }
load(): PersistedGlobalConfig { load(): GlobalConfig {
if (this.cachedConfig !== null) { if (this.cachedConfig !== null) {
return this.cachedConfig; return this.cachedConfig;
} }
@ -78,17 +74,14 @@ export class GlobalConfigManager {
} }
applyGlobalConfigEnvOverrides(rawConfig); applyGlobalConfigEnvOverrides(rawConfig);
const migratedProjectLocalFallback = extractMigratedProjectLocalFallback(rawConfig);
const schemaInput = { ...rawConfig };
removeMigratedProjectLocalKeys(schemaInput);
const parsed = GlobalConfigSchema.parse(schemaInput); const parsed = GlobalConfigSchema.parse(rawConfig);
const normalizedProvider = normalizeConfigProviderReference( const normalizedProvider = normalizeConfigProviderReference(
parsed.provider as RawProviderReference, parsed.provider as RawProviderReference,
parsed.model, parsed.model,
parsed.provider_options as Record<string, unknown> | undefined, parsed.provider_options as Record<string, unknown> | undefined,
); );
const config: PersistedGlobalConfig = { const config: GlobalConfig = {
language: parsed.language, language: parsed.language,
provider: normalizedProvider.provider, provider: normalizedProvider.provider,
model: normalizedProvider.model, model: normalizedProvider.model,
@ -126,9 +119,7 @@ export class GlobalConfigManager {
pieceCategoriesFile: parsed.piece_categories_file, pieceCategoriesFile: parsed.piece_categories_file,
providerOptions: normalizedProvider.providerOptions, providerOptions: normalizedProvider.providerOptions,
providerProfiles: normalizeProviderProfiles(parsed.provider_profiles as Record<string, { default_permission_mode: unknown; movement_permission_overrides?: Record<string, unknown> }> | undefined), providerProfiles: normalizeProviderProfiles(parsed.provider_profiles as Record<string, { default_permission_mode: unknown; movement_permission_overrides?: Record<string, unknown> }> | undefined),
runtime: parsed.runtime?.prepare && parsed.runtime.prepare.length > 0 runtime: normalizeRuntime(parsed.runtime),
? { prepare: [...new Set(parsed.runtime.prepare)] }
: undefined,
preventSleep: parsed.prevent_sleep, preventSleep: parsed.prevent_sleep,
notificationSound: parsed.notification_sound, notificationSound: parsed.notification_sound,
notificationSoundEvents: parsed.notification_sound_events ? { notificationSoundEvents: parsed.notification_sound_events ? {
@ -148,22 +139,25 @@ export class GlobalConfigManager {
personas?: Record<string, { quality_gates?: string[] }>; personas?: Record<string, { quality_gates?: string[] }>;
} | undefined } | undefined
), ),
// Project-local keys (also accepted in global config)
pipeline: normalizePipelineConfig(
parsed.pipeline as { default_branch_prefix?: string; commit_message_template?: string; pr_body_template?: string } | undefined,
),
personaProviders: normalizePersonaProviders(
parsed.persona_providers as Record<string, string | { type?: string; provider?: string; model?: string }> | undefined,
),
branchNameStrategy: parsed.branch_name_strategy as GlobalConfig['branchNameStrategy'],
minimalOutput: parsed.minimal_output as boolean | undefined,
concurrency: parsed.concurrency as number | undefined,
taskPollIntervalMs: parsed.task_poll_interval_ms as number | undefined,
interactivePreviewMovements: parsed.interactive_preview_movements as number | undefined,
}; };
validateProviderModelCompatibility(config.provider, config.model); validateProviderModelCompatibility(config.provider, config.model);
this.cachedConfig = config; this.cachedConfig = config;
this.cachedMigratedProjectLocalFallback = migratedProjectLocalFallback;
return config; return config;
} }
loadMigratedProjectLocalFallback(): GlobalMigratedProjectLocalFallback { save(config: GlobalConfig): void {
if (this.cachedMigratedProjectLocalFallback !== null) {
return this.cachedMigratedProjectLocalFallback;
}
this.load();
return this.cachedMigratedProjectLocalFallback ?? {};
}
save(config: PersistedGlobalConfig): void {
const configPath = getGlobalConfigPath(); const configPath = getGlobalConfigPath();
const raw = serializeGlobalConfig(config); const raw = serializeGlobalConfig(config);
writeFileSync(configPath, stringifyYaml(raw), 'utf-8'); writeFileSync(configPath, stringifyYaml(raw), 'utf-8');
@ -177,14 +171,10 @@ export function invalidateGlobalConfigCache(): void {
invalidateAllResolvedConfigCache(); invalidateAllResolvedConfigCache();
} }
export function loadGlobalConfig(): PersistedGlobalConfig { export function loadGlobalConfig(): GlobalConfig {
return GlobalConfigManager.getInstance().load(); return GlobalConfigManager.getInstance().load();
} }
export function loadGlobalMigratedProjectLocalFallback(): GlobalMigratedProjectLocalFallback { export function saveGlobalConfig(config: GlobalConfig): void {
return GlobalConfigManager.getInstance().loadMigratedProjectLocalFallback();
}
export function saveGlobalConfig(config: PersistedGlobalConfig): void {
GlobalConfigManager.getInstance().save(config); GlobalConfigManager.getInstance().save(config);
} }

View File

@ -1,4 +1,4 @@
import type { PersistedGlobalConfig } from '../../../core/models/persisted-global-config.js'; import type { GlobalConfig } from '../../../core/models/config-types.js';
import { envVarNameFromPath } from '../env/config-env-overrides.js'; import { envVarNameFromPath } from '../env/config-env-overrides.js';
import { loadGlobalConfig, validateCliPath } from './globalConfigCore.js'; import { loadGlobalConfig, validateCliPath } from './globalConfigCore.js';
@ -24,7 +24,7 @@ export function resolveCodexCliPath(): string | undefined {
return validateCliPath(envPath, 'TAKT_CODEX_CLI_PATH'); return validateCliPath(envPath, 'TAKT_CODEX_CLI_PATH');
} }
const config: PersistedGlobalConfig = loadGlobalConfig(); const config: GlobalConfig = loadGlobalConfig();
if (config.codexCliPath === undefined) { if (config.codexCliPath === undefined) {
return undefined; return undefined;
} }
@ -37,7 +37,7 @@ export function resolveClaudeCliPath(): string | undefined {
return validateCliPath(envPath, 'TAKT_CLAUDE_CLI_PATH'); return validateCliPath(envPath, 'TAKT_CLAUDE_CLI_PATH');
} }
const config: PersistedGlobalConfig = loadGlobalConfig(); const config: GlobalConfig = loadGlobalConfig();
if (config.claudeCliPath === undefined) { if (config.claudeCliPath === undefined) {
return undefined; return undefined;
} }
@ -50,7 +50,7 @@ export function resolveCursorCliPath(): string | undefined {
return validateCliPath(envPath, 'TAKT_CURSOR_CLI_PATH'); return validateCliPath(envPath, 'TAKT_CURSOR_CLI_PATH');
} }
const config: PersistedGlobalConfig = loadGlobalConfig(); const config: GlobalConfig = loadGlobalConfig();
if (config.cursorCliPath === undefined) { if (config.cursorCliPath === undefined) {
return undefined; return undefined;
} }
@ -79,7 +79,7 @@ export function resolveCopilotCliPath(): string | undefined {
return validateCliPath(envPath, 'TAKT_COPILOT_CLI_PATH'); return validateCliPath(envPath, 'TAKT_COPILOT_CLI_PATH');
} }
const config: PersistedGlobalConfig = loadGlobalConfig(); const config: GlobalConfig = loadGlobalConfig();
if (config.copilotCliPath === undefined) { if (config.copilotCliPath === undefined) {
return undefined; return undefined;
} }

View File

@ -1,11 +1,11 @@
import type { PersistedGlobalConfig } from '../../../core/models/persisted-global-config.js'; import type { GlobalConfig } from '../../../core/models/config-types.js';
import { import {
denormalizeProviderProfiles, denormalizeProviderProfiles,
denormalizePieceOverrides, denormalizePieceOverrides,
denormalizeProviderOptions, denormalizeProviderOptions,
} from '../configNormalizers.js'; } from '../configNormalizers.js';
export function serializeGlobalConfig(config: PersistedGlobalConfig): Record<string, unknown> { export function serializeGlobalConfig(config: GlobalConfig): Record<string, unknown> {
const raw: Record<string, unknown> = { const raw: Record<string, unknown> = {
language: config.language, language: config.language,
provider: config.provider, provider: config.provider,
@ -147,5 +147,37 @@ export function serializeGlobalConfig(config: PersistedGlobalConfig): Record<str
if (denormalizedPieceOverrides) { if (denormalizedPieceOverrides) {
raw.piece_overrides = denormalizedPieceOverrides; raw.piece_overrides = denormalizedPieceOverrides;
} }
// Project-local keys (also accepted in global config)
if (config.pipeline) {
const pipelineRaw: Record<string, unknown> = {};
if (config.pipeline.defaultBranchPrefix !== undefined) {
pipelineRaw.default_branch_prefix = config.pipeline.defaultBranchPrefix;
}
if (config.pipeline.commitMessageTemplate !== undefined) {
pipelineRaw.commit_message_template = config.pipeline.commitMessageTemplate;
}
if (config.pipeline.prBodyTemplate !== undefined) {
pipelineRaw.pr_body_template = config.pipeline.prBodyTemplate;
}
if (Object.keys(pipelineRaw).length > 0) raw.pipeline = pipelineRaw;
}
if (config.personaProviders && Object.keys(config.personaProviders).length > 0) {
raw.persona_providers = config.personaProviders;
}
if (config.branchNameStrategy !== undefined) {
raw.branch_name_strategy = config.branchNameStrategy;
}
if (config.minimalOutput !== undefined) {
raw.minimal_output = config.minimalOutput;
}
if (config.concurrency !== undefined) {
raw.concurrency = config.concurrency;
}
if (config.taskPollIntervalMs !== undefined) {
raw.task_poll_interval_ms = config.taskPollIntervalMs;
}
if (config.interactivePreviewMovements !== undefined) {
raw.interactive_preview_movements = config.interactivePreviewMovements;
}
return raw; return raw;
} }

View File

@ -1,68 +0,0 @@
import { ProjectConfigSchema } from '../../../core/models/index.js';
import {
normalizePipelineConfig,
normalizePersonaProviders,
} from '../configNormalizers.js';
import {
MIGRATED_PROJECT_LOCAL_CONFIG_METADATA,
type MigratedProjectLocalConfigKey,
} from '../migratedProjectLocalKeys.js';
import type { ProjectLocalConfig } from '../types.js';
export type GlobalMigratedProjectLocalFallback = Partial<
Pick<ProjectLocalConfig, MigratedProjectLocalConfigKey>
>;
export function removeMigratedProjectLocalKeys(config: Record<string, unknown>): void {
for (const metadata of Object.values(MIGRATED_PROJECT_LOCAL_CONFIG_METADATA)) {
delete config[metadata.legacyGlobalYamlKey];
}
}
export function extractMigratedProjectLocalFallback(
rawConfig: Record<string, unknown>,
): GlobalMigratedProjectLocalFallback {
const rawMigratedConfig: Record<string, unknown> = {};
for (const metadata of Object.values(MIGRATED_PROJECT_LOCAL_CONFIG_METADATA)) {
const value = rawConfig[metadata.legacyGlobalYamlKey];
if (value !== undefined) {
rawMigratedConfig[metadata.legacyGlobalYamlKey] = value;
}
}
if (Object.keys(rawMigratedConfig).length === 0) {
return {};
}
const parsedMigratedConfig = ProjectConfigSchema.partial().parse(rawMigratedConfig);
const {
log_level,
pipeline,
persona_providers,
branch_name_strategy,
minimal_output,
verbose,
concurrency,
task_poll_interval_ms,
interactive_preview_movements,
} = parsedMigratedConfig;
return {
logLevel: log_level as ProjectLocalConfig['logLevel'],
pipeline: normalizePipelineConfig(
pipeline as {
default_branch_prefix?: string;
commit_message_template?: string;
pr_body_template?: string;
} | undefined,
),
personaProviders: normalizePersonaProviders(
persona_providers as Record<string, string | { type?: string; provider?: string; model?: string }> | undefined,
),
branchNameStrategy: branch_name_strategy as ProjectLocalConfig['branchNameStrategy'],
minimalOutput: minimal_output as ProjectLocalConfig['minimalOutput'],
verbose: verbose as ProjectLocalConfig['verbose'],
concurrency: concurrency as ProjectLocalConfig['concurrency'],
taskPollIntervalMs: task_poll_interval_ms as ProjectLocalConfig['taskPollIntervalMs'],
interactivePreviewMovements: interactive_preview_movements as ProjectLocalConfig['interactivePreviewMovements'],
};
}

View File

@ -27,7 +27,7 @@ import {
type RawStep = z.output<typeof PieceMovementRawSchema>; type RawStep = z.output<typeof PieceMovementRawSchema>;
import type { MovementProviderOptions } from '../../../core/models/piece-types.js'; import type { MovementProviderOptions } from '../../../core/models/piece-types.js';
import { normalizeRuntime } from '../configNormalizers.js'; import { normalizeRuntime } from '../configNormalizers.js';
import type { PieceOverrides } from '../../../core/models/persisted-global-config.js'; import type { PieceOverrides } from '../../../core/models/config-types.js';
import { applyQualityGateOverrides } from './qualityGateOverrides.js'; import { applyQualityGateOverrides } from './qualityGateOverrides.js';
import { loadProjectConfig } from '../project/projectConfig.js'; import { loadProjectConfig } from '../project/projectConfig.js';
import { loadGlobalConfig } from '../global/globalConfig.js'; import { loadGlobalConfig } from '../global/globalConfig.js';

View File

@ -9,7 +9,7 @@
* Merge strategy: Additive (config gates + YAML gates) * Merge strategy: Additive (config gates + YAML gates)
*/ */
import type { PieceOverrides } from '../../../core/models/persisted-global-config.js'; import type { PieceOverrides } from '../../../core/models/config-types.js';
/** /**
* Apply quality gate overrides to a movement. * Apply quality gate overrides to a movement.

View File

@ -1,18 +0,0 @@
import type { LoadedConfig } from './resolvedConfig.js';
import {
MIGRATED_PROJECT_LOCAL_CONFIG_KEYS,
MIGRATED_PROJECT_LOCAL_CONFIG_METADATA,
type MigratedProjectLocalConfigKey,
} from './migratedProjectLocalKeys.js';
const defaults: Record<string, unknown> = {};
for (const key of MIGRATED_PROJECT_LOCAL_CONFIG_KEYS) {
const metadata = MIGRATED_PROJECT_LOCAL_CONFIG_METADATA[key] as { defaultValue?: unknown };
const defaultValue = metadata.defaultValue;
if (defaultValue !== undefined) {
defaults[key] = defaultValue;
}
}
export const MIGRATED_PROJECT_LOCAL_DEFAULTS =
defaults as Partial<Pick<LoadedConfig, MigratedProjectLocalConfigKey>>;

View File

@ -1,26 +0,0 @@
type MigratedProjectLocalConfigMetadata = {
readonly defaultValue?: unknown;
readonly legacyGlobalYamlKey: string;
};
/**
* Project-local keys migrated from persisted global config.
* Keep this metadata as the single source of truth.
*/
export const MIGRATED_PROJECT_LOCAL_CONFIG_METADATA = {
logLevel: { defaultValue: 'info', legacyGlobalYamlKey: 'log_level' },
pipeline: { legacyGlobalYamlKey: 'pipeline' },
personaProviders: { legacyGlobalYamlKey: 'persona_providers' },
branchNameStrategy: { legacyGlobalYamlKey: 'branch_name_strategy' },
minimalOutput: { defaultValue: false, legacyGlobalYamlKey: 'minimal_output' },
verbose: { defaultValue: false, legacyGlobalYamlKey: 'verbose' },
concurrency: { defaultValue: 1, legacyGlobalYamlKey: 'concurrency' },
taskPollIntervalMs: { defaultValue: 500, legacyGlobalYamlKey: 'task_poll_interval_ms' },
interactivePreviewMovements: { defaultValue: 3, legacyGlobalYamlKey: 'interactive_preview_movements' },
} as const satisfies Record<string, MigratedProjectLocalConfigMetadata>;
export type MigratedProjectLocalConfigKey = keyof typeof MIGRATED_PROJECT_LOCAL_CONFIG_METADATA;
export const MIGRATED_PROJECT_LOCAL_CONFIG_KEYS = Object.freeze(
Object.keys(MIGRATED_PROJECT_LOCAL_CONFIG_METADATA) as MigratedProjectLocalConfigKey[],
);

View File

@ -2,7 +2,7 @@ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import { parse, stringify } from 'yaml'; import { parse, stringify } from 'yaml';
import { ProjectConfigSchema } from '../../../core/models/index.js'; import { ProjectConfigSchema } from '../../../core/models/index.js';
import { copyProjectResourcesToDir } from '../../resources/index.js'; import { copyProjectResourcesToDir } from '../../resources/index.js';
import type { ProjectLocalConfig } from '../types.js'; import type { ProjectConfig } from '../types.js';
import { applyProjectConfigEnvOverrides } from '../env/config-env-overrides.js'; import { applyProjectConfigEnvOverrides } from '../env/config-env-overrides.js';
import { import {
normalizeConfigProviderReference, normalizeConfigProviderReference,
@ -19,8 +19,6 @@ import {
normalizeRuntime, normalizeRuntime,
} from '../configNormalizers.js'; } from '../configNormalizers.js';
import { invalidateResolvedConfigCache } from '../resolutionCache.js'; import { invalidateResolvedConfigCache } from '../resolutionCache.js';
import { MIGRATED_PROJECT_LOCAL_DEFAULTS } from '../migratedProjectLocalDefaults.js';
import type { MigratedProjectLocalConfigKey } from '../migratedProjectLocalKeys.js';
import { getProjectConfigDir, getProjectConfigPath } from './projectConfigPaths.js'; import { getProjectConfigDir, getProjectConfigPath } from './projectConfigPaths.js';
import { import {
normalizeSubmodules, normalizeSubmodules,
@ -30,26 +28,15 @@ import {
formatIssuePath, formatIssuePath,
} from './projectConfigTransforms.js'; } from './projectConfigTransforms.js';
export type { ProjectLocalConfig } from '../types.js'; export type { ProjectConfig as ProjectLocalConfig } from '../types.js';
type Assert<T extends true> = T; type ProviderType = NonNullable<ProjectConfig['provider']>;
type IsNever<T> = [T] extends [never] ? true : false;
/**
* Compile-time guard:
* migrated fields must be owned by ProjectLocalConfig.
*/
const projectLocalConfigMigratedFieldGuard:
Assert<IsNever<Exclude<MigratedProjectLocalConfigKey, keyof ProjectLocalConfig>>> = true;
void projectLocalConfigMigratedFieldGuard;
type ProviderType = NonNullable<ProjectLocalConfig['provider']>;
type RawProviderReference = ConfigProviderReference<ProviderType>; type RawProviderReference = ConfigProviderReference<ProviderType>;
/** /**
* Load project configuration from .takt/config.yaml * Load project configuration from .takt/config.yaml
*/ */
export function loadProjectConfig(projectDir: string): ProjectLocalConfig { export function loadProjectConfig(projectDir: string): ProjectConfig {
const configPath = getProjectConfigPath(projectDir); const configPath = getProjectConfigPath(projectDir);
const rawConfig: Record<string, unknown> = {}; const rawConfig: Record<string, unknown> = {};
if (existsSync(configPath)) { if (existsSync(configPath)) {
@ -91,10 +78,8 @@ export function loadProjectConfig(projectDir: string): ProjectLocalConfig {
provider_options, provider_options,
provider_profiles, provider_profiles,
analytics, analytics,
log_level,
pipeline, pipeline,
persona_providers, persona_providers,
verbose,
branch_name_strategy, branch_name_strategy,
minimal_output, minimal_output,
concurrency, concurrency,
@ -102,7 +87,6 @@ export function loadProjectConfig(projectDir: string): ProjectLocalConfig {
interactive_preview_movements, interactive_preview_movements,
piece_overrides, piece_overrides,
runtime, runtime,
...rest
} = parsedConfig; } = parsedConfig;
const normalizedProvider = normalizeConfigProviderReference( const normalizedProvider = normalizeConfigProviderReference(
provider as RawProviderReference, provider as RawProviderReference,
@ -115,21 +99,18 @@ export function loadProjectConfig(projectDir: string): ProjectLocalConfig {
const normalizedPipeline = normalizePipelineConfig( const normalizedPipeline = normalizePipelineConfig(
pipeline as { default_branch_prefix?: string; commit_message_template?: string; pr_body_template?: string } | undefined, pipeline as { default_branch_prefix?: string; commit_message_template?: string; pr_body_template?: string } | undefined,
); );
const personaProviders = normalizePersonaProviders( const normalizedPersonaProviders = normalizePersonaProviders(
persona_providers as Record<string, string | { type?: string; provider?: string; model?: string }> | undefined, persona_providers as Record<string, string | { type?: string; provider?: string; model?: string }> | undefined,
); );
return { return {
...(rest as ProjectLocalConfig),
logLevel: log_level as ProjectLocalConfig['logLevel'],
pipeline: normalizedPipeline, pipeline: normalizedPipeline,
personaProviders, personaProviders: normalizedPersonaProviders,
branchNameStrategy: branch_name_strategy as ProjectLocalConfig['branchNameStrategy'], branchNameStrategy: branch_name_strategy as ProjectConfig['branchNameStrategy'],
minimalOutput: minimal_output as boolean | undefined, minimalOutput: minimal_output as boolean | undefined,
concurrency: concurrency as number | undefined, concurrency: concurrency as number | undefined,
taskPollIntervalMs: task_poll_interval_ms as number | undefined, taskPollIntervalMs: task_poll_interval_ms as number | undefined,
interactivePreviewMovements: interactive_preview_movements as number | undefined, interactivePreviewMovements: interactive_preview_movements as number | undefined,
verbose: verbose as boolean | undefined,
autoPr: auto_pr as boolean | undefined, autoPr: auto_pr as boolean | undefined,
draftPr: draft_pr as boolean | undefined, draftPr: draft_pr as boolean | undefined,
baseBranch: base_branch as string | undefined, baseBranch: base_branch as string | undefined,
@ -155,7 +136,7 @@ export function loadProjectConfig(projectDir: string): ProjectLocalConfig {
/** /**
* Save project configuration to .takt/config.yaml * Save project configuration to .takt/config.yaml
*/ */
export function saveProjectConfig(projectDir: string, config: ProjectLocalConfig): void { export function saveProjectConfig(projectDir: string, config: ProjectConfig): void {
const configDir = getProjectConfigDir(projectDir); const configDir = getProjectConfigDir(projectDir);
const configPath = getProjectConfigPath(projectDir); const configPath = getProjectConfigPath(projectDir);
if (!existsSync(configDir)) { if (!existsSync(configDir)) {
@ -187,49 +168,15 @@ export function saveProjectConfig(projectDir: string, config: ProjectLocalConfig
} }
delete savePayload.providerProfiles; delete savePayload.providerProfiles;
delete savePayload.providerOptions; delete savePayload.providerOptions;
delete savePayload.concurrency;
delete savePayload.verbose;
if (config.autoPr !== undefined) savePayload.auto_pr = config.autoPr; if (config.autoPr !== undefined) savePayload.auto_pr = config.autoPr;
if (config.draftPr !== undefined) savePayload.draft_pr = config.draftPr; if (config.draftPr !== undefined) savePayload.draft_pr = config.draftPr;
if (config.baseBranch !== undefined) savePayload.base_branch = config.baseBranch; if (config.baseBranch !== undefined) savePayload.base_branch = config.baseBranch;
if (
config.logLevel !== undefined
&& config.logLevel !== MIGRATED_PROJECT_LOCAL_DEFAULTS.logLevel
) {
savePayload.log_level = config.logLevel;
}
if (config.branchNameStrategy !== undefined) savePayload.branch_name_strategy = config.branchNameStrategy; if (config.branchNameStrategy !== undefined) savePayload.branch_name_strategy = config.branchNameStrategy;
if ( if (config.minimalOutput !== undefined) savePayload.minimal_output = config.minimalOutput;
config.minimalOutput !== undefined if (config.taskPollIntervalMs !== undefined) savePayload.task_poll_interval_ms = config.taskPollIntervalMs;
&& config.minimalOutput !== MIGRATED_PROJECT_LOCAL_DEFAULTS.minimalOutput if (config.interactivePreviewMovements !== undefined) savePayload.interactive_preview_movements = config.interactivePreviewMovements;
) { if (config.concurrency !== undefined) savePayload.concurrency = config.concurrency;
savePayload.minimal_output = config.minimalOutput;
}
if (
config.taskPollIntervalMs !== undefined
&& config.taskPollIntervalMs !== MIGRATED_PROJECT_LOCAL_DEFAULTS.taskPollIntervalMs
) {
savePayload.task_poll_interval_ms = config.taskPollIntervalMs;
}
if (
config.interactivePreviewMovements !== undefined
&& config.interactivePreviewMovements !== MIGRATED_PROJECT_LOCAL_DEFAULTS.interactivePreviewMovements
) {
savePayload.interactive_preview_movements = config.interactivePreviewMovements;
}
if (
config.concurrency !== undefined
&& config.concurrency !== MIGRATED_PROJECT_LOCAL_DEFAULTS.concurrency
) {
savePayload.concurrency = config.concurrency;
}
if (
config.verbose !== undefined
&& config.verbose !== MIGRATED_PROJECT_LOCAL_DEFAULTS.verbose
) {
savePayload.verbose = config.verbose;
}
delete savePayload.pipeline; delete savePayload.pipeline;
if (config.pipeline) { if (config.pipeline) {
const pipelineRaw: Record<string, unknown> = {}; const pipelineRaw: Record<string, unknown> = {};
@ -264,7 +211,6 @@ export function saveProjectConfig(projectDir: string, config: ProjectLocalConfig
delete savePayload.draftPr; delete savePayload.draftPr;
delete savePayload.baseBranch; delete savePayload.baseBranch;
delete savePayload.withSubmodules; delete savePayload.withSubmodules;
delete savePayload.logLevel;
delete savePayload.branchNameStrategy; delete savePayload.branchNameStrategy;
delete savePayload.minimalOutput; delete savePayload.minimalOutput;
delete savePayload.taskPollIntervalMs; delete savePayload.taskPollIntervalMs;
@ -289,10 +235,10 @@ export function saveProjectConfig(projectDir: string, config: ProjectLocalConfig
invalidateResolvedConfigCache(projectDir); invalidateResolvedConfigCache(projectDir);
} }
export function updateProjectConfig<K extends keyof ProjectLocalConfig>( export function updateProjectConfig<K extends keyof ProjectConfig>(
projectDir: string, projectDir: string,
key: K, key: K,
value: ProjectLocalConfig[K] value: ProjectConfig[K]
): void { ): void {
const config = loadProjectConfig(projectDir); const config = loadProjectConfig(projectDir);
config[key] = value; config[key] = value;

View File

@ -1,4 +1,4 @@
import type { AnalyticsConfig, SubmoduleSelection } from '../../../core/models/persisted-global-config.js'; import type { AnalyticsConfig, SubmoduleSelection } from '../../../core/models/config-types.js';
const SUBMODULES_ALL = 'all'; const SUBMODULES_ALL = 'all';

View File

@ -1,5 +1,5 @@
import { isVerboseShortcutEnabled } from '../resolveConfigValue.js'; import { isDebugLoggingEnabled } from '../resolveConfigValue.js';
export function isVerboseMode(projectDir: string): boolean { export function isVerboseMode(projectDir: string): boolean {
return isVerboseShortcutEnabled(projectDir); return isDebugLoggingEnabled(projectDir);
} }

View File

@ -9,11 +9,6 @@ import {
setCachedResolvedValue, setCachedResolvedValue,
} from './resolutionCache.js'; } from './resolutionCache.js';
import type { ConfigParameterKey, LoadedConfig } from './resolvedConfig.js'; import type { ConfigParameterKey, LoadedConfig } from './resolvedConfig.js';
import { MIGRATED_PROJECT_LOCAL_DEFAULTS } from './migratedProjectLocalDefaults.js';
import {
MIGRATED_PROJECT_LOCAL_CONFIG_KEYS,
type MigratedProjectLocalConfigKey,
} from './migratedProjectLocalKeys.js';
export type { ConfigParameterKey } from './resolvedConfig.js'; export type { ConfigParameterKey } from './resolvedConfig.js';
export { invalidateResolvedConfigCache, invalidateAllResolvedConfigCache } from './resolutionCache.js'; export { invalidateResolvedConfigCache, invalidateAllResolvedConfigCache } from './resolutionCache.js';
@ -41,9 +36,14 @@ interface ResolutionRule<K extends ConfigParameterKey> {
mergeMode?: 'analytics'; mergeMode?: 'analytics';
pieceValue?: (pieceContext: PieceContext | undefined) => LoadedConfig[K] | undefined; pieceValue?: (pieceContext: PieceContext | undefined) => LoadedConfig[K] | undefined;
} }
type GlobalMigratedProjectLocalFallback = Partial<
Pick<LoadedConfig, MigratedProjectLocalConfigKey> /** Default values for project-local keys that need NonNullable guarantees */
>; const PROJECT_LOCAL_DEFAULTS: Partial<Record<ConfigParameterKey, unknown>> = {
minimalOutput: false,
concurrency: 1,
taskPollIntervalMs: 500,
interactivePreviewMovements: 3,
};
function loadProjectConfigCached(projectDir: string) { function loadProjectConfigCached(projectDir: string) {
const cached = getCachedProjectConfig(projectDir); const cached = getCachedProjectConfig(projectDir);
@ -67,15 +67,7 @@ const PROVIDER_OPTIONS_ENV_PATHS = [
'provider_options.claude.sandbox.excluded_commands', 'provider_options.claude.sandbox.excluded_commands',
] as const; ] as const;
const MIGRATED_PROJECT_LOCAL_RESOLUTION_REGISTRY = Object.fromEntries(
MIGRATED_PROJECT_LOCAL_CONFIG_KEYS.map((key) => [key, { layers: ['local', 'global'] as const }]),
) as Partial<{ [K in ConfigParameterKey]: ResolutionRule<K> }>;
const MIGRATED_PROJECT_LOCAL_CONFIG_KEY_SET = new Set(
MIGRATED_PROJECT_LOCAL_CONFIG_KEYS as ConfigParameterKey[],
);
const RESOLUTION_REGISTRY: Partial<{ [K in ConfigParameterKey]: ResolutionRule<K> }> = { const RESOLUTION_REGISTRY: Partial<{ [K in ConfigParameterKey]: ResolutionRule<K> }> = {
logLevel: { layers: ['local', 'global'] },
provider: { provider: {
layers: ['local', 'piece', 'global'], layers: ['local', 'piece', 'global'],
pieceValue: (pieceContext) => pieceContext?.provider, pieceValue: (pieceContext) => pieceContext?.provider,
@ -91,7 +83,6 @@ const RESOLUTION_REGISTRY: Partial<{ [K in ConfigParameterKey]: ResolutionRule<K
autoPr: { layers: ['local', 'global'] }, autoPr: { layers: ['local', 'global'] },
draftPr: { layers: ['local', 'global'] }, draftPr: { layers: ['local', 'global'] },
analytics: { layers: ['local', 'global'], mergeMode: 'analytics' }, analytics: { layers: ['local', 'global'], mergeMode: 'analytics' },
...MIGRATED_PROJECT_LOCAL_RESOLUTION_REGISTRY,
autoFetch: { layers: ['global'] }, autoFetch: { layers: ['global'] },
baseBranch: { layers: ['local', 'global'] }, baseBranch: { layers: ['local', 'global'] },
pieceOverrides: { layers: ['local', 'global'] }, pieceOverrides: { layers: ['local', 'global'] },
@ -132,16 +123,8 @@ function getLocalLayerValue<K extends ConfigParameterKey>(
function getGlobalLayerValue<K extends ConfigParameterKey>( function getGlobalLayerValue<K extends ConfigParameterKey>(
global: ReturnType<typeof globalConfigModule.loadGlobalConfig>, global: ReturnType<typeof globalConfigModule.loadGlobalConfig>,
globalMigratedProjectLocalFallback: GlobalMigratedProjectLocalFallback,
key: K, key: K,
): LoadedConfig[K] | undefined { ): LoadedConfig[K] | undefined {
if (key === 'logLevel' && global.logging?.level !== undefined) {
return global.logging.level as LoadedConfig[K];
}
if (isMigratedProjectLocalConfigKey(key)) {
return globalMigratedProjectLocalFallback[key] as LoadedConfig[K] | undefined;
}
return global[key as keyof typeof global] as LoadedConfig[K] | undefined; return global[key as keyof typeof global] as LoadedConfig[K] | undefined;
} }
@ -149,7 +132,6 @@ function resolveByRegistry<K extends ConfigParameterKey>(
key: K, key: K,
project: ReturnType<typeof loadProjectConfigCached>, project: ReturnType<typeof loadProjectConfigCached>,
global: ReturnType<typeof globalConfigModule.loadGlobalConfig>, global: ReturnType<typeof globalConfigModule.loadGlobalConfig>,
globalMigratedProjectLocalFallback: GlobalMigratedProjectLocalFallback,
options: ResolveConfigOptions | undefined, options: ResolveConfigOptions | undefined,
): ResolvedConfigValue<K> { ): ResolvedConfigValue<K> {
const rule = (RESOLUTION_REGISTRY[key] ?? DEFAULT_RULE) as ResolutionRule<K>; const rule = (RESOLUTION_REGISTRY[key] ?? DEFAULT_RULE) as ResolutionRule<K>;
@ -167,7 +149,7 @@ function resolveByRegistry<K extends ConfigParameterKey>(
} else if (layer === 'piece') { } else if (layer === 'piece') {
value = rule.pieceValue?.(options?.pieceContext); value = rule.pieceValue?.(options?.pieceContext);
} else { } else {
value = getGlobalLayerValue(global, globalMigratedProjectLocalFallback, key); value = getGlobalLayerValue(global, key);
} }
if (value !== undefined) { if (value !== undefined) {
if (layer === 'local') { if (layer === 'local') {
@ -183,7 +165,7 @@ function resolveByRegistry<K extends ConfigParameterKey>(
} }
} }
const fallbackDefaultValue = MIGRATED_PROJECT_LOCAL_DEFAULTS[key as keyof typeof MIGRATED_PROJECT_LOCAL_DEFAULTS]; const fallbackDefaultValue = PROJECT_LOCAL_DEFAULTS[key];
if (fallbackDefaultValue !== undefined) { if (fallbackDefaultValue !== undefined) {
return { value: fallbackDefaultValue as LoadedConfig[K], source: 'default' }; return { value: fallbackDefaultValue as LoadedConfig[K], source: 'default' };
} }
@ -202,16 +184,7 @@ function resolveUncachedConfigValue<K extends ConfigParameterKey>(
): ResolvedConfigValue<K> { ): ResolvedConfigValue<K> {
const project = loadProjectConfigCached(projectDir); const project = loadProjectConfigCached(projectDir);
const global = globalConfigModule.loadGlobalConfig(); const global = globalConfigModule.loadGlobalConfig();
const globalMigratedProjectLocalFallback = isMigratedProjectLocalConfigKey(key) return resolveByRegistry(key, project, global, options);
? globalConfigModule.loadGlobalMigratedProjectLocalFallback()
: {};
return resolveByRegistry(key, project, global, globalMigratedProjectLocalFallback, options);
}
function isMigratedProjectLocalConfigKey(
key: ConfigParameterKey,
): key is MigratedProjectLocalConfigKey {
return MIGRATED_PROJECT_LOCAL_CONFIG_KEY_SET.has(key);
} }
export function resolveConfigValueWithSource<K extends ConfigParameterKey>( export function resolveConfigValueWithSource<K extends ConfigParameterKey>(
@ -249,19 +222,10 @@ export function resolveConfigValues<K extends ConfigParameterKey>(
return result; return result;
} }
export function isVerboseShortcutEnabled( export function isDebugLoggingEnabled(
projectDir: string, projectDir: string,
options?: ResolveConfigOptions, options?: ResolveConfigOptions,
): boolean { ): boolean {
const verbose = resolveConfigValue(projectDir, 'verbose', options);
if (verbose === true) {
return true;
}
const logging = resolveConfigValue(projectDir, 'logging', options); const logging = resolveConfigValue(projectDir, 'logging', options);
if (logging?.debug === true || logging?.trace === true) { return logging?.debug === true || logging?.trace === true || logging?.level === 'debug';
return true;
}
return resolveConfigValue(projectDir, 'logLevel', options) === 'debug';
} }

View File

@ -1,16 +1,13 @@
import type { PersistedGlobalConfig } from '../../core/models/persisted-global-config.js'; import type { GlobalConfig } from '../../core/models/config-types.js';
import type { ProjectLocalConfig } from './types.js'; import type { ProjectConfig } from './types.js';
import type { MigratedProjectLocalConfigKey } from './migratedProjectLocalKeys.js';
export interface LoadedConfig export interface LoadedConfig
extends PersistedGlobalConfig, extends GlobalConfig,
Pick<ProjectLocalConfig, MigratedProjectLocalConfigKey> { ProjectConfig {
logLevel: NonNullable<ProjectLocalConfig['logLevel']>; minimalOutput: NonNullable<ProjectConfig['minimalOutput']>;
minimalOutput: NonNullable<ProjectLocalConfig['minimalOutput']>; concurrency: NonNullable<ProjectConfig['concurrency']>;
verbose: NonNullable<ProjectLocalConfig['verbose']>; taskPollIntervalMs: NonNullable<ProjectConfig['taskPollIntervalMs']>;
concurrency: NonNullable<ProjectLocalConfig['concurrency']>; interactivePreviewMovements: NonNullable<ProjectConfig['interactivePreviewMovements']>;
taskPollIntervalMs: NonNullable<ProjectLocalConfig['taskPollIntervalMs']>;
interactivePreviewMovements: NonNullable<ProjectLocalConfig['interactivePreviewMovements']>;
} }
export type ConfigParameterKey = keyof LoadedConfig; export type ConfigParameterKey = keyof LoadedConfig;

View File

@ -1,62 +1,11 @@
/** /**
* Config module type definitions * Config module type definitions
*
* ProjectConfig is now defined in core/models/config-types.ts.
* This file re-exports it for backward compatibility within the config module.
*/ */
import type { MovementProviderOptions, PieceRuntimeConfig } from '../../core/models/piece-types.js'; export type { ProjectConfig, ProjectConfig as ProjectLocalConfig } from '../../core/models/config-types.js';
import type { ProviderPermissionProfiles } from '../../core/models/provider-profiles.js';
import type {
AnalyticsConfig,
PersonaProviderEntry,
PieceOverrides,
PipelineConfig,
SubmoduleSelection,
} from '../../core/models/persisted-global-config.js';
/** Project configuration stored in .takt/config.yaml */
export interface ProjectLocalConfig {
/** Provider selection for agent runtime */
provider?: 'claude' | 'codex' | 'opencode' | 'cursor' | 'copilot' | 'mock';
/** Model selection for agent runtime */
model?: string;
/** Auto-create PR after worktree execution */
autoPr?: boolean;
/** Create PR as draft */
draftPr?: boolean;
/** Base branch to clone from (overrides global baseBranch) */
baseBranch?: string;
/** Submodule acquisition mode (all or explicit path list) */
submodules?: SubmoduleSelection;
/** Compatibility flag for full submodule acquisition when submodules is unset */
withSubmodules?: boolean;
/** Verbose output mode */
verbose?: boolean;
/** Project log level */
logLevel?: 'debug' | 'info' | 'warn' | 'error';
/** Pipeline execution settings */
pipeline?: PipelineConfig;
/** Per-persona provider/model overrides */
personaProviders?: Record<string, PersonaProviderEntry>;
/** Branch name generation strategy */
branchNameStrategy?: 'romaji' | 'ai';
/** Minimal output mode */
minimalOutput?: boolean;
/** Number of tasks to run concurrently in takt run (1-10) */
concurrency?: number;
/** Polling interval in ms for task pickup */
taskPollIntervalMs?: number;
/** Number of movement previews in interactive mode */
interactivePreviewMovements?: number;
/** Project-level analytics overrides */
analytics?: AnalyticsConfig;
/** Provider-specific options (overrides global, overridden by piece/movement) */
providerOptions?: MovementProviderOptions;
/** Provider-specific permission profiles (project-level override) */
providerProfiles?: ProviderPermissionProfiles;
/** Piece-level overrides (quality_gates, etc.) */
pieceOverrides?: PieceOverrides;
/** Runtime environment configuration (project-level override) */
runtime?: PieceRuntimeConfig;
}
/** Persona session data for persistence */ /** Persona session data for persistence */
export interface PersonaSessionData { export interface PersonaSessionData {