Compare commits

...

7 Commits

Author SHA1 Message Date
nrs
16596eff09
takt: refactor-status-handling (#477) 2026-03-06 01:40:25 +09:00
nrs
bc5e1fd860
takt: fix-phase3-fallback-bypass (#474) 2026-03-06 01:30:33 +09:00
nrslib
a8223d231d 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 に修正
2026-03-06 01:29:46 +09:00
nrslib
ebbd1a67a9 fix: ピース再利用確認を task.data.piece から取得 & config テンプレート拡充
- retryFailedTask / instructBranch でピース名の取得元を
  runInfo?.piece から task.data?.piece に変更
  (worktree 内に .takt/runs/ が存在しないため runInfo は常に null だった)
- ~/.takt/config.yaml テンプレートに不足していた設定項目を追加
  (provider, model, concurrency, analytics, pipeline, persona_providers 等)
2026-03-06 00:37:54 +09:00
nrs
a69e9f4fb3
takt: add-persona-quality-gates (#472) 2026-03-05 23:32:32 +09:00
nrs
7bfc7954aa
Merge pull request #473 from nrslib/release/v0.30.0
Release v0.30.0
2026-03-05 23:18:17 +09:00
nrslib
903111dd74 feat: team leader の分解品質を改善するナレッジとインストラクションを追加
- knowledge/task-decomposition.md: 分解の可否判断基準、ファイル排他原則、
  失敗パターン(パート重複・共有契約不整合)をドメイン知識として追加
- team-leader-implement instruction: 分解前に可否を判断するステップを追加。
  横断的関心事・少ファイル・リファクタ系は1パートで実装するよう指示
- takt-default-team-leader.yaml: implement movement に task-decomposition
  ナレッジを追加
2026-03-05 23:16:32 +09:00
71 changed files with 1807 additions and 1219 deletions

View File

@ -2,17 +2,35 @@
# Location: ~/.takt/config.yaml
# =====================================
# General settings (piece-independent)
# General settings
# =====================================
# Note: this template contains global-only settings for ~/.takt/config.yaml.
language: en # UI language: en | ja
# Default provider and model
# provider: claude # Default provider: claude | codex | opencode | cursor | copilot | mock
# model: sonnet # Default model (passed directly to provider)
# Execution control
# worktree_dir: ~/takt-worktrees # Base directory for shared clone execution
# prevent_sleep: false # Prevent macOS idle sleep while running
# auto_fetch: false # Fetch before clone to keep shared clones up-to-date
# base_branch: main # Base branch to clone from (default: current branch)
# concurrency: 1 # Number of tasks to run concurrently in takt run (1-10)
# task_poll_interval_ms: 500 # Polling interval in ms for picking up new tasks (100-5000)
# PR / branch
# auto_pr: false # Auto-create PR after worktree execution
# draft_pr: false # Create PR as draft
# branch_name_strategy: romaji # Branch name generation: romaji | ai
# Pipeline execution
# pipeline:
# default_branch_prefix: "takt/" # Branch prefix for pipeline-created branches
# commit_message_template: "{title}" # Commit message template. Variables: {title}, {issue}
# pr_body_template: "{report}" # PR body template. Variables: {issue_body}, {report}, {issue}
# Output / notifications
# minimal_output: false # Suppress detailed agent output
# notification_sound: true # Master switch for sounds
# notification_sound_events: # Per-event sound toggle (unset means true)
# iteration_limit: true
@ -20,12 +38,63 @@ language: en # UI language: en | ja
# piece_abort: true
# run_complete: true
# run_abort: true
# verbose: false # Shortcut: enable trace/debug and set logging.level=debug
# logging:
# level: info # Log level for console and file output
# trace: true # Generate human-readable execution trace report (trace.md)
# debug: false # Enable debug.log + prompts.jsonl
# provider_events: false # Persist provider stream events
# provider_events: false # Persist provider stream events
# usage_events: false # Persist usage event logs
# Analytics
# analytics:
# enabled: true # Enable local analytics collection
# events_path: ~/.takt/analytics/events # Custom events directory
# retention_days: 30 # Retention period for event files
# Interactive mode
# interactive_preview_movements: 3 # Number of movement previews in interactive mode (0-10)
# Per-persona provider/model overrides
# persona_providers:
# coder:
# provider: claude
# model: opus
# reviewer:
# provider: codex
# model: gpt-5.2-codex
# Provider-specific options (lowest priority, overridden by piece/movement)
# provider_options:
# codex:
# network_access: true
# claude:
# sandbox:
# allow_unsandboxed_commands: true
# Provider permission profiles
# provider_profiles:
# claude:
# default_permission_mode: edit
# codex:
# default_permission_mode: edit
# Runtime environment preparation
# runtime:
# prepare: [node, gradle, ./custom-script.sh]
# Piece-level overrides
# piece_overrides:
# quality_gates:
# - "All tests pass"
# quality_gates_edit_only: true
# movements:
# review:
# quality_gates:
# - "No security vulnerabilities"
# personas:
# coder:
# quality_gates:
# - "Code follows conventions"
# Credentials (environment variables take priority)
# anthropic_api_key: "sk-ant-..." # Claude API key
@ -35,7 +104,14 @@ language: en # UI language: en | ja
# groq_api_key: "..." # Groq API key
# openrouter_api_key: "..." # OpenRouter API key
# opencode_api_key: "..." # OpenCode API key
# codex_cli_path: "/absolute/path/to/codex" # Absolute path to Codex CLI
# cursor_api_key: "..." # Cursor API key
# CLI paths
# codex_cli_path: "/absolute/path/to/codex" # Absolute path to Codex CLI
# claude_cli_path: "/absolute/path/to/claude" # Absolute path to Claude Code CLI
# cursor_cli_path: "/absolute/path/to/cursor" # Absolute path to cursor-agent CLI
# copilot_cli_path: "/absolute/path/to/copilot" # Absolute path to Copilot CLI
# copilot_github_token: "ghp_..." # Copilot GitHub token
# Misc
# bookmarks_file: ~/.takt/preferences/bookmarks.yaml # Bookmark file location

View File

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

View File

@ -1,17 +1,19 @@
Decompose the implementation task into subtasks by file ownership and execute them in parallel. Assign files exclusively to each part to prevent conflicts.
Analyze the implementation task and, if decomposition is appropriate, split into multiple parts for parallel execution.
**Important:** Reference the plan report: {report:plan.md}
**Steps:**
1. Identify files to create/modify
- Reference the plan report and test scope to list all files to change
- Review the actual codebase to fill in any missing information
1. Assess whether decomposition is appropriate
- Identify files to change and check inter-file dependencies
- If cross-cutting concerns exist (shared types, IDs, events), implement in a single part
- If few files are involved, or the task is a rename/refactoring, implement in a single part
2. Group files by layer/module
2. If decomposing: group files by layer/module
- Create groups based on high cohesion (e.g., Domain layer / Infrastructure layer / API layer)
- If there are type or interface dependencies, keep both sides in the same group
- Never assign the same file to multiple parts
- Keep test files and implementation files in the same part
3. Assign file ownership exclusively to each part
- Each part's instruction must clearly state:

View File

@ -0,0 +1,66 @@
# Task Decomposition Knowledge
## Decomposition Feasibility
Before splitting a task into multiple parts, assess whether decomposition is appropriate. When decomposition is unsuitable, implementing in a single part is more efficient.
| Criteria | Judgment |
|----------|----------|
| Changed files clearly separate into layers | Decompose |
| Shared types/IDs span multiple parts | Single part |
| Broad rename/refactoring | Single part |
| Fewer than 5 files to change | Single part |
| Same file needs editing by multiple parts | Single part |
### Detecting Cross-Cutting Concerns
When any of the following apply, independent parts cannot maintain consistency. Consolidate into a single part.
- A new ID, key, or type is generated in one module and consumed in another
- Both the event emitter and event receiver need changes
- An existing interface signature changes, requiring updates to all call sites
## File Exclusivity Principle
When decomposing into multiple parts, each part's file ownership must be completely exclusive.
| Criteria | Judgment |
|----------|----------|
| Same file edited by multiple parts | REJECT (causes conflicts) |
| Type definition and consumer in different parts | Consolidate into the type definition part |
| Test file and implementation file in different parts | Consolidate into the same part |
### Grouping Priority
1. **By dependency direction** — keep dependency source and target in the same part
2. **By layer** — domain layer / infrastructure layer / API layer
3. **By feature** — independent functional units
## Failure Patterns
### Part Overlap
When two parts own the same file or feature, sub-agents overwrite each other's changes, causing repeated REJECT in reviews.
```
// NG: part-2 and part-3 own the same file
part-2: taskInstructionActions.ts — instruct confirmation dialog
part-3: taskInstructionActions.ts — requeue confirmation dialog
// OK: consolidate into one part
part-1: taskInstructionActions.ts — both instruct/requeue confirmation dialogs
```
### Shared Contract Mismatch
When part A generates an ID that part B consumes, both parts implement independently, leading to mismatches in ID name, type, or passing mechanism.
```
// NG: shared contract across independent parts
part-1: generates phaseExecutionId
part-2: consumes phaseExecutionId
→ part-1 uses string, part-2 expects number → integration error
// OK: single part for consistent implementation
part-1: implements phaseExecutionId from generation to consumption
```

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? |
| Operation | Actually works as expected? |
### 6. Backward Compatibility Code Detection
**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
### 6. Spec Compliance Final Check
**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 "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
- **Actually run**: Don't just look at files, execute and verify

View File

@ -15,19 +15,7 @@ loop_monitors:
threshold: 3
judge:
persona: supervisor
instruction_template: |
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?
instruction_template: loop-monitor-ai-fix
rules:
- condition: Healthy (making progress)
next: ai_review
@ -124,6 +112,7 @@ movements:
knowledge:
- takt
- architecture
- task-decomposition
provider_options:
claude:
allowed_tools:
@ -157,7 +146,7 @@ movements:
- condition: No implementation (report only)
next: ai_review
- condition: Cannot proceed, insufficient info
next: ai_review
next: plan
- condition: User input required
next: implement
requires_user_input: true
@ -391,6 +380,7 @@ movements:
edit: false
persona: supervisor
policy: review
knowledge: architecture
provider_options:
claude:
allowed_tools:

View File

@ -2,17 +2,35 @@
# 配置場所: ~/.takt/config.yaml
# =====================================
# 通常設定(ピース非依存)
# 通常設定
# =====================================
# 注意: このテンプレートは global 専用設定(~/.takt/config.yamlだけを扱う
language: ja # 表示言語: ja | en
# デフォルトプロバイダー・モデル
# provider: claude # デフォルトプロバイダー: claude | codex | opencode | cursor | copilot | mock
# model: sonnet # デフォルトモデル(プロバイダーにそのまま渡される)
# 実行制御
# worktree_dir: ~/takt-worktrees # 共有clone作成先ディレクトリ
# prevent_sleep: false # macOS実行中のスリープ防止caffeinate
# auto_fetch: false # clone前にfetchして最新化するか
# base_branch: main # cloneのベースブランチデフォルト: カレントブランチ)
# concurrency: 1 # takt run の同時実行タスク数 (1-10)
# task_poll_interval_ms: 500 # 新規タスク検出のポーリング間隔ms, 100-5000
# PR / ブランチ
# auto_pr: false # worktree実行後にPR自動作成
# draft_pr: false # ドラフトPRとして作成
# branch_name_strategy: romaji # ブランチ名生成: romaji | ai
# パイプライン実行
# pipeline:
# default_branch_prefix: "takt/" # パイプラインで作成するブランチのプレフィックス
# commit_message_template: "{title}" # コミットメッセージテンプレート。変数: {title}, {issue}
# pr_body_template: "{report}" # PR本文テンプレート。変数: {issue_body}, {report}, {issue}
# 出力・通知
# minimal_output: false # エージェント詳細出力を抑制
# notification_sound: true # 通知音全体のON/OFF
# notification_sound_events: # イベント別通知音未指定はtrue扱い
# iteration_limit: true
@ -20,12 +38,63 @@ language: ja # 表示言語: ja | en
# piece_abort: true
# run_complete: true
# run_abort: true
# verbose: false # ショートカット: trace/debug有効化 + logging.level=debug
# logging:
# level: info # ログレベル: debug | info | warn | error
# trace: true # trace.md 実行レポート生成
# debug: false # debug.log + prompts.jsonl を有効化
# provider_events: false # providerイベントログを記録
# usage_events: false # 使用量イベントログを記録
# アナリティクス
# analytics:
# enabled: true # ローカルアナリティクス収集を有効化
# events_path: ~/.takt/analytics/events # イベントディレクトリのカスタムパス
# retention_days: 30 # イベントファイルの保持期間(日)
# インタラクティブモード
# interactive_preview_movements: 3 # インタラクティブモードでのムーブメントプレビュー数 (0-10)
# ペルソナ別プロバイダー・モデル指定
# persona_providers:
# coder:
# provider: claude
# model: opus
# reviewer:
# provider: codex
# model: gpt-5.2-codex
# プロバイダー固有オプション(最低優先度、ピース/ムーブメントで上書き可能)
# provider_options:
# codex:
# network_access: true
# claude:
# sandbox:
# allow_unsandboxed_commands: true
# プロバイダー権限プロファイル
# provider_profiles:
# claude:
# default_permission_mode: edit
# codex:
# default_permission_mode: edit
# ランタイム環境の準備
# runtime:
# prepare: [node, gradle, ./custom-script.sh]
# ピースレベルのオーバーライド
# piece_overrides:
# quality_gates:
# - "All tests pass"
# quality_gates_edit_only: true
# movements:
# review:
# quality_gates:
# - "No security vulnerabilities"
# personas:
# coder:
# quality_gates:
# - "Code follows conventions"
# 認証情報(環境変数優先)
# anthropic_api_key: "sk-ant-..." # Claude APIキー
@ -35,7 +104,14 @@ language: ja # 表示言語: ja | en
# groq_api_key: "..." # Groq APIキー
# openrouter_api_key: "..." # OpenRouter APIキー
# opencode_api_key: "..." # OpenCode APIキー
# codex_cli_path: "/absolute/path/to/codex" # Codex CLI絶対パス
# cursor_api_key: "..." # Cursor APIキー
# CLIパス
# codex_cli_path: "/absolute/path/to/codex" # Codex CLI絶対パス
# claude_cli_path: "/absolute/path/to/claude" # Claude Code CLI絶対パス
# cursor_cli_path: "/absolute/path/to/cursor" # cursor-agent CLI絶対パス
# copilot_cli_path: "/absolute/path/to/copilot" # Copilot CLI絶対パス
# copilot_github_token: "ghp_..." # Copilot GitHubトークン
# その他
# bookmarks_file: ~/.takt/preferences/bookmarks.yaml # ブックマーク保存先

View File

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

View File

@ -1,17 +1,19 @@
実装タスクをファイル担当単位でサブタスクに分解し、並列実行してください。各パートが担当するファイルが重複しないよう排他的に割り当てます
実装タスクを分析し、分解が適切なら複数パートに分けて並列実行してください
**重要:** 計画レポートを参照してください: {report:plan.md}
**やること:**
1. 変更対象ファイルを特定する
- 計画レポートとテストスコープを参照して変更・作成するファイルを洗い出す
- 実際のコードベースを確認して不明点を補完する
1. 分解の可否を判断する
- 変更対象ファイルを特定し、ファイル間の依存関係を確認する
- 横断的関心事共有型・ID・イベントがある場合は分解せず1パートで実装する
- 変更ファイル数が少ない場合、リファクタ・リネーム系の場合も1パートで実装する
2. ファイルをレイヤー/モジュール単位でグループ化する
2. 分解する場合: ファイルをレイヤー/モジュール単位でグループ化する
- 凝集度の高い単位でグループを作る(例: ドメイン層 / インフラ層 / API層
- 型・インターフェースの依存がある場合は、依存元と依存先を同じグループにまとめる
- 1つのファイルを複数のパートに割り当てない
- テストファイルと実装ファイルは同じパートにまとめる
3. 各パートに排他的なファイル担当を割り当てる
- 各パートの instruction に以下を必ず明記する:

View File

@ -0,0 +1,66 @@
# タスク分解知識
## 分解の可否判断
タスクを複数パートに分解する前に、分解が適切かを判断する。分解が不適切なケースでは1パートで実装する方が効率的。
| 基準 | 判定 |
|------|------|
| 変更ファイルが明確にレイヤー分離できる | 分解可 |
| 共有する型・IDが複数パートをまたぐ | 1パートで実装 |
| 広範囲のリネーム・リファクタ | 1パートで実装 |
| 変更ファイル数が5未満 | 1パートで実装 |
| 同一ファイルを複数パートが触る必要がある | 1パートで実装 |
### 横断的関心事の検出
以下に該当する場合、独立パートでは整合性を保てない。1パートにまとめる。
- 新しいID・キー・型を生成し、別のモジュールで消費するフロー
- イベント発火側と受信側の両方を変更する
- 既存インターフェースのシグネチャを変更し、全呼び出し元を更新する
## ファイル排他の原則
複数パートに分解する場合、各パートの担当ファイルは完全に排他的でなければならない。
| 基準 | 判定 |
|------|------|
| 同一ファイルを複数パートが編集 | REJECTコンフリクトの原因 |
| 型定義と利用側が別パート | 型定義側のパートにまとめる |
| テストファイルと実装ファイルが別パート | 同じパートにまとめる |
### グループ化の優先順位
1. **依存方向で分ける** — 依存元と依存先は同じパートに
2. **レイヤーで分ける** — ドメイン層 / インフラ層 / API層
3. **機能で分ける** — 独立した機能単位
## 失敗パターン
### パート重複
2つのパートが同じファイルや同じ機能を担当すると、サブエージェントが互いの変更を上書きし、レビューで繰り返しREJECTされる。
```
// NG: part-2 と part-3 が同じファイルを担当
part-2: taskInstructionActions.ts — instruct機能の確認ダイアログ
part-3: taskInstructionActions.ts — requeue機能の確認ダイアログ
// OK: 1パートにまとめる
part-1: taskInstructionActions.ts — instruct/requeue両方の確認ダイアログ
```
### 共有契約の不整合
パートAが生成するIDをパートBが消費する設計では、両パートが独立に実装するため、ID名・型・受け渡し方法に不整合が生じる。
```
// NG: 独立パートで共有契約
part-1: phaseExecutionId を生成
part-2: phaseExecutionId を受け取って使う
→ part-1 は string、part-2 は number を期待 → 統合エラー
// OK: 1パートで一貫実装
part-1: phaseExecutionId の生成から消費まで一貫して実装
```

View File

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

View File

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

View File

@ -0,0 +1,71 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const { mockExecuteClaudeCli } = vi.hoisted(() => ({
mockExecuteClaudeCli: vi.fn(),
}));
vi.mock('../infra/claude/process.js', () => ({
executeClaudeCli: mockExecuteClaudeCli,
}));
vi.mock('../shared/utils/index.js', () => ({
createLogger: vi.fn(() => ({
error: vi.fn(),
warn: vi.fn(),
info: vi.fn(),
debug: vi.fn(),
})),
}));
vi.mock('../shared/prompts/index.js', () => ({
loadTemplate: vi.fn(() => 'system prompt'),
}));
import { ClaudeClient } from '../infra/claude/client.js';
import type { ClaudeCallOptions } from '../infra/claude/client.js';
describe('ClaudeClient status normalization', () => {
const options: ClaudeCallOptions = {
cwd: '/tmp/takt-test',
};
beforeEach(() => {
vi.clearAllMocks();
});
it('should return error status when call() receives an interrupted failure', async () => {
mockExecuteClaudeCli.mockResolvedValue({
success: false,
interrupted: true,
content: 'Interrupted by signal',
error: 'SIGINT',
sessionId: 'session-1',
});
const client = new ClaudeClient();
const response = await client.call('coder', 'Implement feature', options);
expect(response.status).toBe('error');
expect(response.error).toBe('SIGINT');
expect(response.content).toBe('Interrupted by signal');
});
it('should return error status when callCustom() receives an interrupted failure', async () => {
mockExecuteClaudeCli.mockResolvedValue({
success: false,
interrupted: true,
content: 'Interrupted by signal',
error: 'SIGINT',
sessionId: 'session-2',
});
const client = new ClaudeClient();
const response = await client.callCustom('custom-coder', 'Implement feature', 'system prompt', options);
expect(response.status).toBe('error');
expect(response.error).toBe('SIGINT');
expect(response.content).toBe('Interrupted by signal');
});
});

View File

@ -1,9 +1,9 @@
import { describe, expect, it } from 'vitest';
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');
expect('loadGlobalMigratedProjectLocalFallback' in globalConfig).toBe(true);
expect('loadGlobalMigratedProjectLocalFallback' in globalConfig).toBe(false);
});
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', () => {
process.env.TAKT_MODEL = 'gpt-5';
process.env.TAKT_VERBOSE = 'true';
process.env.TAKT_CONCURRENCY = '3';
process.env.TAKT_ANALYTICS_EVENTS_PATH = '/tmp/project-analytics';
@ -61,7 +60,6 @@ describe('config env overrides', () => {
applyProjectConfigEnvOverrides(raw);
expect(raw.model).toBe('gpt-5');
expect(raw.verbose).toBe(true);
expect(raw.concurrency).toBe(3);
expect(raw.analytics).toEqual({
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

@ -37,6 +37,7 @@ import {
isVerboseMode,
resolveConfigValue,
invalidateGlobalConfigCache,
invalidateAllResolvedConfigCache,
} from '../infra/config/index.js';
let isolatedGlobalConfigDir: string;
@ -270,6 +271,351 @@ describe('loadPiece (builtin fallback)', () => {
});
});
describe('loadPiece piece_overrides.personas integration', () => {
let testDir: string;
beforeEach(() => {
testDir = join(tmpdir(), `takt-test-${randomUUID()}`);
mkdirSync(join(testDir, '.takt', 'pieces'), { recursive: true });
});
afterEach(() => {
invalidateGlobalConfigCache();
invalidateAllResolvedConfigCache();
if (existsSync(testDir)) {
rmSync(testDir, { recursive: true, force: true });
}
});
it('should apply persona quality gates from global then project configs', () => {
// Given: global/project persona overrides and piece yaml quality gates
writeFileSync(
join(isolatedGlobalConfigDir, 'config.yaml'),
[
'language: en',
'piece_overrides:',
' personas:',
' coder:',
' quality_gates:',
' - "Global persona gate"',
].join('\n'),
'utf-8',
);
writeFileSync(
join(testDir, '.takt', 'config.yaml'),
[
'piece_overrides:',
' personas:',
' coder:',
' quality_gates:',
' - "Project persona gate"',
].join('\n'),
'utf-8',
);
writeFileSync(
join(testDir, '.takt', 'pieces', 'persona-gates.yaml'),
[
'name: persona-gates',
'description: Persona quality gates integration test',
'max_movements: 3',
'initial_movement: implement',
'movements:',
' - name: implement',
' persona: coder',
' edit: true',
' quality_gates:',
' - "YAML gate"',
' rules:',
' - condition: Done',
' next: COMPLETE',
' instruction: "{task}"',
].join('\n'),
'utf-8',
);
invalidateGlobalConfigCache();
invalidateAllResolvedConfigCache();
// When: loading the piece through normal config pipeline
const piece = loadPiece('persona-gates', testDir);
// Then: persona gates are merged in global -> project -> yaml order
const movement = piece?.movements.find((step) => step.name === 'implement');
expect(movement?.qualityGates).toEqual([
'Global persona gate',
'Project persona gate',
'YAML gate',
]);
});
it('should apply persona quality gates when movement persona uses personas section alias key', () => {
// Given: piece persona alias key differs from mapped persona filename
writeFileSync(
join(isolatedGlobalConfigDir, 'config.yaml'),
[
'language: en',
'piece_overrides:',
' personas:',
' coder:',
' quality_gates:',
' - "Alias key gate"',
].join('\n'),
'utf-8',
);
mkdirSync(join(testDir, '.takt', 'pieces', 'personas'), { recursive: true });
writeFileSync(join(testDir, '.takt', 'pieces', 'personas', 'implementer.md'), 'Implementer persona', 'utf-8');
writeFileSync(
join(testDir, '.takt', 'pieces', 'persona-alias-key.yaml'),
[
'name: persona-alias-key',
'description: personas alias key should drive override matching',
'max_movements: 3',
'initial_movement: implement',
'personas:',
' coder: ./personas/implementer.md',
'movements:',
' - name: implement',
' persona: coder',
' quality_gates:',
' - "YAML gate"',
' rules:',
' - condition: Done',
' next: COMPLETE',
' instruction: "{task}"',
].join('\n'),
'utf-8',
);
invalidateGlobalConfigCache();
invalidateAllResolvedConfigCache();
// When: loading piece with section alias persona reference
const piece = loadPiece('persona-alias-key', testDir);
// Then: override key is alias key ("coder"), not mapped filename ("implementer")
const movement = piece?.movements.find((step) => step.name === 'implement');
expect(movement?.qualityGates).toEqual(['Alias key gate', 'YAML gate']);
});
it('should apply persona quality gates for path personas using basename key', () => {
// Given: movement persona is a path and override key uses its basename
writeFileSync(
join(isolatedGlobalConfigDir, 'config.yaml'),
[
'language: en',
'piece_overrides:',
' personas:',
' implementer:',
' quality_gates:',
' - "Path basename gate"',
].join('\n'),
'utf-8',
);
mkdirSync(join(testDir, '.takt', 'pieces', 'personas'), { recursive: true });
writeFileSync(join(testDir, '.takt', 'pieces', 'personas', 'implementer.md'), 'Implementer persona', 'utf-8');
writeFileSync(
join(testDir, '.takt', 'pieces', 'persona-path-key.yaml'),
[
'name: persona-path-key',
'description: path personas should match overrides by basename',
'max_movements: 3',
'initial_movement: implement',
'movements:',
' - name: implement',
' persona: ./personas/implementer.md',
' quality_gates:',
' - "YAML gate"',
' rules:',
' - condition: Done',
' next: COMPLETE',
' instruction: "{task}"',
].join('\n'),
'utf-8',
);
invalidateGlobalConfigCache();
invalidateAllResolvedConfigCache();
// When: loading piece with path-like persona reference
const piece = loadPiece('persona-path-key', testDir);
// Then: override key resolves from path basename ("implementer")
const movement = piece?.movements.find((step) => step.name === 'implement');
expect(movement?.qualityGates).toEqual(['Path basename gate', 'YAML gate']);
});
it('should not apply persona quality gates when persona does not match', () => {
// Given: persona overrides exist only for reviewer
writeFileSync(
join(isolatedGlobalConfigDir, 'config.yaml'),
[
'language: en',
'piece_overrides:',
' personas:',
' reviewer:',
' quality_gates:',
' - "Reviewer gate"',
].join('\n'),
'utf-8',
);
writeFileSync(
join(testDir, '.takt', 'pieces', 'persona-mismatch.yaml'),
[
'name: persona-mismatch',
'description: Persona mismatch integration test',
'max_movements: 3',
'initial_movement: implement',
'movements:',
' - name: implement',
' persona: coder',
' quality_gates:',
' - "YAML gate"',
' rules:',
' - condition: Done',
' next: COMPLETE',
' instruction: "{task}"',
].join('\n'),
'utf-8',
);
invalidateGlobalConfigCache();
invalidateAllResolvedConfigCache();
// When: loading piece with different persona
const piece = loadPiece('persona-mismatch', testDir);
// Then: only YAML gates are applied
const movement = piece?.movements.find((step) => step.name === 'implement');
expect(movement?.qualityGates).toEqual(['YAML gate']);
});
it('should not apply persona quality gates when movement has no persona', () => {
writeFileSync(
join(isolatedGlobalConfigDir, 'config.yaml'),
[
'language: en',
'piece_overrides:',
' personas:',
' reviewer:',
' quality_gates:',
' - "Reviewer gate"',
].join('\n'),
'utf-8',
);
writeFileSync(
join(testDir, '.takt', 'pieces', 'no-persona-reviewer.yaml'),
[
'name: no-persona-reviewer',
'description: No persona movement should not match persona overrides',
'max_movements: 3',
'initial_movement: reviewer',
'movements:',
' - name: reviewer',
' quality_gates:',
' - "YAML gate"',
' rules:',
' - condition: Done',
' next: COMPLETE',
' instruction: "{task}"',
].join('\n'),
'utf-8',
);
invalidateGlobalConfigCache();
invalidateAllResolvedConfigCache();
const piece = loadPiece('no-persona-reviewer', testDir);
const movement = piece?.movements.find((step) => step.name === 'reviewer');
expect(movement?.qualityGates).toEqual(['YAML gate']);
});
it('should not apply persona quality gates from persona_name without persona', () => {
writeFileSync(
join(isolatedGlobalConfigDir, 'config.yaml'),
[
'language: en',
'piece_overrides:',
' personas:',
' reviewer:',
' quality_gates:',
' - "Reviewer gate"',
].join('\n'),
'utf-8',
);
writeFileSync(
join(testDir, '.takt', 'pieces', 'persona-name-only.yaml'),
[
'name: persona-name-only',
'description: persona_name should be display-only for persona overrides',
'max_movements: 3',
'initial_movement: review',
'movements:',
' - name: review',
' persona_name: reviewer',
' quality_gates:',
' - "YAML gate"',
' rules:',
' - condition: Done',
' next: COMPLETE',
' instruction: "{task}"',
].join('\n'),
'utf-8',
);
invalidateGlobalConfigCache();
invalidateAllResolvedConfigCache();
const piece = loadPiece('persona-name-only', testDir);
const movement = piece?.movements.find((step) => step.name === 'review');
expect(movement?.qualityGates).toEqual(['YAML gate']);
});
it('should throw when movement persona is an empty string', () => {
writeFileSync(
join(testDir, '.takt', 'pieces', 'empty-persona.yaml'),
[
'name: empty-persona',
'description: Empty persona should fail fast',
'max_movements: 3',
'initial_movement: implement',
'movements:',
' - name: implement',
' persona: " "',
' rules:',
' - condition: Done',
' next: COMPLETE',
' instruction: "{task}"',
].join('\n'),
'utf-8',
);
invalidateGlobalConfigCache();
invalidateAllResolvedConfigCache();
expect(() => loadPiece('empty-persona', testDir)).toThrow('Movement "implement" has an empty persona value');
});
it('should throw when movement persona_name is an empty string', () => {
writeFileSync(
join(testDir, '.takt', 'pieces', 'empty-persona-name.yaml'),
[
'name: empty-persona-name',
'description: Empty persona_name should fail fast',
'max_movements: 3',
'initial_movement: implement',
'movements:',
' - name: implement',
' persona: coder',
' persona_name: " "',
' rules:',
' - condition: Done',
' next: COMPLETE',
' instruction: "{task}"',
].join('\n'),
'utf-8',
);
invalidateGlobalConfigCache();
invalidateAllResolvedConfigCache();
expect(() => loadPiece('empty-persona-name', testDir)).toThrow('Movement "implement" has an empty persona_name value');
});
});
describe('listPieces (builtin fallback)', () => {
let testDir: string;
@ -560,7 +906,6 @@ describe('analytics config resolution', () => {
describe('isVerboseMode', () => {
let testDir: string;
let originalTaktConfigDir: string | undefined;
let originalTaktVerbose: string | undefined;
let originalTaktLoggingDebug: string | undefined;
let originalTaktLoggingTrace: string | undefined;
@ -568,11 +913,9 @@ describe('isVerboseMode', () => {
testDir = join(tmpdir(), `takt-test-${randomUUID()}`);
mkdirSync(testDir, { recursive: true });
originalTaktConfigDir = process.env.TAKT_CONFIG_DIR;
originalTaktVerbose = process.env.TAKT_VERBOSE;
originalTaktLoggingDebug = process.env.TAKT_LOGGING_DEBUG;
originalTaktLoggingTrace = process.env.TAKT_LOGGING_TRACE;
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_TRACE;
invalidateGlobalConfigCache();
@ -584,11 +927,6 @@ describe('isVerboseMode', () => {
} else {
process.env.TAKT_CONFIG_DIR = originalTaktConfigDir;
}
if (originalTaktVerbose === undefined) {
delete process.env.TAKT_VERBOSE;
} else {
process.env.TAKT_VERBOSE = originalTaktVerbose;
}
if (originalTaktLoggingDebug === undefined) {
delete process.env.TAKT_LOGGING_DEBUG;
} else {
@ -605,43 +943,7 @@ describe('isVerboseMode', () => {
}
});
it('should return project verbose when project config has verbose: true', () => {
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', () => {
it('should return false when neither project nor global logging.debug is set', () => {
expect(isVerboseMode(testDir)).toBe(false);
});
@ -705,28 +1007,10 @@ describe('isVerboseMode', () => {
expect(isVerboseMode(testDir)).toBe(true);
});
it('should prioritize TAKT_VERBOSE over project and global config', () => {
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');
process.env.TAKT_VERBOSE = 'true';
it('should return true when TAKT_LOGGING_DEBUG=true overrides config', () => {
process.env.TAKT_LOGGING_DEBUG = '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', () => {

View File

@ -37,7 +37,7 @@ vi.mock('../shared/utils/index.js', async (importOriginal) => ({
import { PieceEngine } from '../core/piece/index.js';
import { runAgent } from '../agents/runner.js';
import { detectMatchedRule } from '../core/piece/evaluation/index.js';
import { runReportPhase } from '../core/piece/phase-runner.js';
import { needsStatusJudgmentPhase, runReportPhase, runStatusJudgmentPhase } from '../core/piece/phase-runner.js';
import {
makeResponse,
makeMovement,
@ -113,11 +113,50 @@ describe('PieceEngine Integration: Error Handling', () => {
});
// =====================================================
// 2.5 Phase 3 fallback
// =====================================================
describe('Phase 3 fallback', () => {
it('should continue with phase1 rule evaluation when status judgment throws', async () => {
const config = buildDefaultPieceConfig({
initialMovement: 'plan',
movements: [
makeMovement('plan', {
rules: [makeRule('continue', 'COMPLETE')],
}),
],
});
const engine = new PieceEngine(config, tmpDir, 'test task', { projectCwd: tmpDir });
vi.mocked(needsStatusJudgmentPhase).mockReturnValue(true);
vi.mocked(runStatusJudgmentPhase).mockRejectedValueOnce(new Error('Phase 3 failed'));
mockRunAgentSequence([
makeResponse({ persona: 'plan', content: '[STEP:1] continue' }),
]);
mockDetectMatchedRuleSequence([
{ index: 0, method: 'phase1_tag' },
]);
const state = await engine.run();
expect(state.status).toBe('completed');
expect(runStatusJudgmentPhase).toHaveBeenCalledOnce();
expect(detectMatchedRule).toHaveBeenCalledWith(
expect.objectContaining({ name: 'plan' }),
'[STEP:1] continue',
'',
expect.any(Object),
);
expect(state.movementOutputs.get('plan')?.matchedRuleMethod).toBe('phase1_tag');
});
});
// =====================================================
// 3. Interrupted status routing
// =====================================================
describe('Interrupted status', () => {
it('should continue with normal rule routing and skip report phase when movement returns interrupted', async () => {
describe('Error status', () => {
it('should abort immediately and skip report phase when movement returns error', async () => {
const config = buildDefaultPieceConfig({
initialMovement: 'plan',
movements: [
@ -130,11 +169,12 @@ describe('PieceEngine Integration: Error Handling', () => {
const engine = new PieceEngine(config, tmpDir, 'test task', { projectCwd: tmpDir });
mockRunAgentSequence([
makeResponse({ persona: 'plan', status: 'interrupted', content: 'Partial response' }),
]);
mockDetectMatchedRuleSequence([
{ index: 0, method: 'phase1_tag' },
makeResponse({
persona: 'plan',
status: 'error',
content: 'Partial response',
error: 'interrupted by signal',
}),
]);
const abortFn = vi.fn();
@ -142,10 +182,107 @@ describe('PieceEngine Integration: Error Handling', () => {
const state = await engine.run();
expect(state.status).toBe('completed');
expect(abortFn).not.toHaveBeenCalled();
expect(state.status).toBe('aborted');
expect(abortFn).toHaveBeenCalledOnce();
expect(runReportPhase).not.toHaveBeenCalled();
});
it('should abort when movement returns an unhandled status and skip report phase', async () => {
const config = buildDefaultPieceConfig({
initialMovement: 'plan',
movements: [
makeMovement('plan', {
outputContracts: [{ name: '01-plan.md', format: '# Plan' }],
rules: [makeRule('continue', 'COMPLETE')],
}),
],
});
const engine = new PieceEngine(config, tmpDir, 'test task', { projectCwd: tmpDir });
mockRunAgentSequence([
makeResponse({
persona: 'plan',
status: 'pending' as never,
content: 'pending response',
}),
]);
const abortFn = vi.fn();
engine.on('piece:abort', abortFn);
const state = await engine.run();
expect(state.status).toBe('aborted');
expect(abortFn).toHaveBeenCalledOnce();
const reason = abortFn.mock.calls[0]![1] as string;
expect(reason).toContain('Unhandled response status: pending');
expect(runReportPhase).not.toHaveBeenCalled();
});
});
describe('runSingleIteration status routing', () => {
it('should abort without rule resolution when movement returns blocked', async () => {
const config = buildDefaultPieceConfig({
initialMovement: 'plan',
movements: [
makeMovement('plan', {
rules: [makeRule('continue', 'COMPLETE')],
}),
],
});
const engine = new PieceEngine(config, tmpDir, 'test task', { projectCwd: tmpDir });
mockRunAgentSequence([
makeResponse({
persona: 'plan',
status: 'blocked',
content: 'need input',
}),
]);
const abortFn = vi.fn();
engine.on('piece:abort', abortFn);
const result = await engine.runSingleIteration();
expect(result.nextMovement).toBe('ABORT');
expect(result.isComplete).toBe(true);
expect(engine.getState().status).toBe('aborted');
expect(abortFn).toHaveBeenCalledOnce();
});
it('should abort without rule resolution when movement returns error', async () => {
const config = buildDefaultPieceConfig({
initialMovement: 'plan',
movements: [
makeMovement('plan', {
rules: [makeRule('continue', 'COMPLETE')],
}),
],
});
const engine = new PieceEngine(config, tmpDir, 'test task', { projectCwd: tmpDir });
mockRunAgentSequence([
makeResponse({
persona: 'plan',
status: 'error',
content: 'failed',
error: 'request failed',
}),
]);
const abortFn = vi.fn();
engine.on('piece:abort', abortFn);
const result = await engine.runSingleIteration();
expect(result.nextMovement).toBe('ABORT');
expect(result.isComplete).toBe(true);
expect(engine.getState().status).toBe('aborted');
expect(abortFn).toHaveBeenCalledOnce();
const reason = abortFn.mock.calls[0]![1] as string;
expect(reason).toContain('Movement "plan" failed: request failed');
});
});
// =====================================================

View File

@ -39,6 +39,7 @@ vi.mock('../shared/utils/index.js', async (importOriginal) => ({
import { PieceEngine } from '../core/piece/index.js';
import { runAgent } from '../agents/runner.js';
import { runReportPhase } from '../core/piece/phase-runner.js';
import {
makeResponse,
makeMovement,
@ -208,6 +209,40 @@ describe('PieceEngine Integration: Loop Monitors', () => {
// 8 iterations: impl + ai_review*3 + ai_fix*2 + judge + reviewers
expect(state.iteration).toBe(8);
});
it('should abort when judge returns non-done status', async () => {
const config = buildConfigWithLoopMonitor(1);
engine = new PieceEngine(config, tmpDir, 'test task', { projectCwd: tmpDir });
mockRunAgentSequence([
makeResponse({ persona: 'implement', content: 'Implementation done' }),
makeResponse({ persona: 'ai_review', content: 'Issues found: X' }),
makeResponse({ persona: 'ai_fix', content: 'Fixed X' }),
makeResponse({
persona: 'supervisor',
status: 'error',
content: 'judge failed',
error: 'judge interrupted',
}),
]);
mockDetectMatchedRuleSequence([
{ index: 0, method: 'phase1_tag' },
{ index: 1, method: 'phase1_tag' },
{ index: 0, method: 'phase1_tag' },
]);
const abortFn = vi.fn();
engine.on('piece:abort', abortFn);
const state = await engine.run();
expect(state.status).toBe('aborted');
expect(abortFn).toHaveBeenCalledOnce();
const reason = abortFn.mock.calls[0]![1] as string;
expect(reason).toContain('Unhandled response status: error');
expect(runReportPhase).not.toHaveBeenCalled();
});
});
// =====================================================

View File

@ -36,6 +36,7 @@ vi.mock('../shared/utils/index.js', async (importOriginal) => ({
import { PieceEngine } from '../core/piece/index.js';
import { runAgent } from '../agents/runner.js';
import { detectMatchedRule } from '../core/piece/evaluation/index.js';
import { needsStatusJudgmentPhase, runStatusJudgmentPhase } from '../core/piece/phase-runner.js';
import {
makeResponse,
makeMovement,
@ -215,4 +216,59 @@ describe('PieceEngine Integration: Parallel Movement Partial Failure', () => {
expect(archReviewOutput!.error).toBe('Session resume failed');
expect(archReviewOutput!.content).toBe('');
});
it('should fallback to phase1 rule evaluation when sub-movement phase3 throws', async () => {
const config = buildParallelOnlyConfig();
const engine = new PieceEngine(config, tmpDir, 'test task', { projectCwd: tmpDir });
vi.mocked(needsStatusJudgmentPhase).mockImplementation((movement) => {
return movement.name === 'arch-review' || movement.name === 'security-review';
});
vi.mocked(runStatusJudgmentPhase).mockImplementation(async (movement) => {
if (movement.name === 'arch-review') {
throw new Error('Phase 3 failed for arch-review');
}
return { tag: '', ruleIndex: 0, method: 'auto_select' };
});
const mock = vi.mocked(runAgent);
mock.mockImplementationOnce(async (persona, task, options) => {
options?.onPromptResolved?.({
systemPrompt: typeof persona === 'string' ? persona : '',
userInstruction: task,
});
return makeResponse({ persona: 'arch-review', content: '[STEP:1] done' });
});
mock.mockImplementationOnce(async (persona, task, options) => {
options?.onPromptResolved?.({
systemPrompt: typeof persona === 'string' ? persona : '',
userInstruction: task,
});
return makeResponse({ persona: 'security-review', content: '[STEP:1] done' });
});
mock.mockImplementationOnce(async (persona, task, options) => {
options?.onPromptResolved?.({
systemPrompt: typeof persona === 'string' ? persona : '',
userInstruction: task,
});
return makeResponse({ persona: 'done', content: 'completed' });
});
mockDetectMatchedRuleSequence([
{ index: 0, method: 'phase1_tag' }, // arch-review fallback
{ index: 0, method: 'aggregate' }, // reviewers aggregate
{ index: 0, method: 'phase1_tag' }, // done -> COMPLETE
]);
const state = await engine.run();
expect(state.status).toBe('completed');
expect(state.movementOutputs.get('arch-review')?.status).toBe('done');
expect(state.movementOutputs.get('arch-review')?.matchedRuleMethod).toBe('phase1_tag');
expect(
vi.mocked(detectMatchedRule).mock.calls.some(([movement, content, tagContent]) => {
return movement.name === 'arch-review' && content === '[STEP:1] done' && tagContent === '';
}),
).toBe(true);
});
});

View File

@ -24,7 +24,6 @@ const {
loadGlobalConfig,
saveGlobalConfig,
invalidateGlobalConfigCache,
loadGlobalMigratedProjectLocalFallback,
} = await import('../infra/config/global/globalConfig.js');
const { getGlobalConfigPath } = await import('../infra/config/paths.js');
@ -48,28 +47,25 @@ describe('loadGlobalConfig', () => {
expect(config.model).toBeUndefined();
});
it('should not expose migrated project-local fields from global config', () => {
const config = loadGlobalConfig() as Record<string, unknown>;
it('should not have project-local fields set by default', () => {
const config = loadGlobalConfig();
expect(config).not.toHaveProperty('logLevel');
expect(config).not.toHaveProperty('pipeline');
expect(config).not.toHaveProperty('personaProviders');
expect(config).not.toHaveProperty('branchNameStrategy');
expect(config).not.toHaveProperty('minimalOutput');
expect(config).not.toHaveProperty('concurrency');
expect(config).not.toHaveProperty('taskPollIntervalMs');
expect(config).not.toHaveProperty('interactivePreviewMovements');
expect(config).not.toHaveProperty('verbose');
expect(config.pipeline).toBeUndefined();
expect(config.personaProviders).toBeUndefined();
expect(config.branchNameStrategy).toBeUndefined();
expect(config.minimalOutput).toBeUndefined();
expect(config.concurrency).toBeUndefined();
expect(config.taskPollIntervalMs).toBeUndefined();
expect(config.interactivePreviewMovements).toBeUndefined();
});
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');
mkdirSync(taktDir, { recursive: true });
writeFileSync(
getGlobalConfigPath(),
[
'language: en',
'log_level: debug',
'pipeline:',
' default_branch_prefix: "global/"',
'persona_providers:',
@ -80,31 +76,27 @@ describe('loadGlobalConfig', () => {
'concurrency: 3',
'task_poll_interval_ms: 1000',
'interactive_preview_movements: 2',
'verbose: true',
].join('\n'),
'utf-8',
);
expect(() => loadGlobalConfig()).not.toThrow();
const config = loadGlobalConfig() as Record<string, unknown>;
expect(config).not.toHaveProperty('logLevel');
expect(config).not.toHaveProperty('pipeline');
expect(config).not.toHaveProperty('personaProviders');
expect(config).not.toHaveProperty('branchNameStrategy');
expect(config).not.toHaveProperty('minimalOutput');
expect(config).not.toHaveProperty('concurrency');
expect(config).not.toHaveProperty('taskPollIntervalMs');
expect(config).not.toHaveProperty('interactivePreviewMovements');
expect(config).not.toHaveProperty('verbose');
const config = loadGlobalConfig();
expect(config.pipeline).toEqual({ defaultBranchPrefix: 'global/' });
expect(config.personaProviders).toEqual({ coder: { provider: 'codex' } });
expect(config.branchNameStrategy).toBe('ai');
expect(config.minimalOutput).toBe(true);
expect(config.concurrency).toBe(3);
expect(config.taskPollIntervalMs).toBe(1000);
expect(config.interactivePreviewMovements).toBe(2);
});
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');
mkdirSync(taktDir, { recursive: true });
writeFileSync(getGlobalConfigPath(), 'language: en\n', 'utf-8');
const config = loadGlobalConfig() as Record<string, unknown>;
config.logLevel = 'debug';
const config = loadGlobalConfig();
config.pipeline = { defaultBranchPrefix: 'global/' };
config.personaProviders = { coder: { provider: 'codex' } };
config.branchNameStrategy = 'ai';
@ -112,19 +104,16 @@ describe('loadGlobalConfig', () => {
config.concurrency = 4;
config.taskPollIntervalMs = 1200;
config.interactivePreviewMovements = 1;
config.verbose = true;
saveGlobalConfig(config as Parameters<typeof saveGlobalConfig>[0]);
saveGlobalConfig(config);
const raw = readFileSync(getGlobalConfigPath(), 'utf-8');
expect(raw).not.toContain('log_level:');
expect(raw).not.toContain('pipeline:');
expect(raw).not.toContain('persona_providers:');
expect(raw).not.toContain('branch_name_strategy:');
expect(raw).not.toContain('minimal_output:');
expect(raw).not.toContain('concurrency:');
expect(raw).not.toContain('task_poll_interval_ms:');
expect(raw).not.toContain('interactive_preview_movements:');
expect(raw).not.toContain('verbose:');
expect(raw).toContain('pipeline:');
expect(raw).toContain('persona_providers:');
expect(raw).toContain('branch_name_strategy:');
expect(raw).toContain('minimal_output:');
expect(raw).toContain('concurrency:');
expect(raw).toContain('task_poll_interval_ms:');
expect(raw).toContain('interactive_preview_movements:');
});
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');
mkdirSync(taktDir, { recursive: true });
writeFileSync(
@ -279,18 +268,20 @@ describe('loadGlobalConfig', () => {
);
expect(() => loadGlobalConfig()).not.toThrow();
const config = loadGlobalConfig() as Record<string, unknown>;
expect(config).not.toHaveProperty('pipeline');
const config = loadGlobalConfig();
expect(config.pipeline).toEqual({
defaultBranchPrefix: 'feat/',
commitMessageTemplate: 'fix: {title} (#{issue})',
});
});
it('should save and reload pipeline config', () => {
const taktDir = join(testHomeDir, '.takt');
mkdirSync(taktDir, { recursive: true });
// Create minimal config first
writeFileSync(getGlobalConfigPath(), 'language: en\n', 'utf-8');
const config = loadGlobalConfig();
(config as Record<string, unknown>).pipeline = {
config.pipeline = {
defaultBranchPrefix: 'takt/',
commitMessageTemplate: 'feat: {title} (#{issue})',
};
@ -298,7 +289,10 @@ describe('loadGlobalConfig', () => {
invalidateGlobalConfigCache();
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', () => {
@ -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');
mkdirSync(taktDir, { recursive: true });
writeFileSync(
@ -641,8 +635,8 @@ describe('loadGlobalConfig', () => {
);
expect(() => loadGlobalConfig()).not.toThrow();
const config = loadGlobalConfig() as Record<string, unknown>;
expect(config).not.toHaveProperty('interactivePreviewMovements');
const config = loadGlobalConfig();
expect(config.interactivePreviewMovements).toBe(5);
});
it('should save and reload interactive_preview_movements config', () => {
@ -651,24 +645,24 @@ describe('loadGlobalConfig', () => {
writeFileSync(getGlobalConfigPath(), 'language: en\n', 'utf-8');
const config = loadGlobalConfig();
(config as Record<string, unknown>).interactivePreviewMovements = 7;
config.interactivePreviewMovements = 7;
saveGlobalConfig(config);
invalidateGlobalConfigCache();
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');
mkdirSync(taktDir, { recursive: true });
writeFileSync(getGlobalConfigPath(), 'language: en\n', 'utf-8');
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');
mkdirSync(taktDir, { recursive: true });
writeFileSync(
@ -678,8 +672,8 @@ describe('loadGlobalConfig', () => {
);
expect(() => loadGlobalConfig()).not.toThrow();
const config = loadGlobalConfig() as Record<string, unknown>;
expect(config).not.toHaveProperty('interactivePreviewMovements');
const config = loadGlobalConfig();
expect(config.interactivePreviewMovements).toBe(0);
});
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 { join } from 'node:path';
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
let testConfigPath: string;
@ -102,7 +102,7 @@ piece_overrides:
});
it('should preserve non-empty quality_gates array', () => {
const config: PersistedGlobalConfig = {
const config: GlobalConfig = {
pieceOverrides: {
qualityGates: ['Test 1', 'Test 2'],
},
@ -117,6 +117,61 @@ piece_overrides:
expect(reloaded.pieceOverrides?.qualityGates).toEqual(['Test 1', 'Test 2']);
});
it('should preserve personas quality_gates in save/load cycle', () => {
const configContent = `
piece_overrides:
personas:
coder:
quality_gates:
- "Global persona gate"
`;
writeFileSync(testConfigPath, configContent, 'utf-8');
const manager = GlobalConfigManager.getInstance();
const loaded = manager.load();
const loadedPieceOverrides = loaded.pieceOverrides as unknown as {
personas?: Record<string, { qualityGates?: string[] }>;
};
expect(loadedPieceOverrides.personas?.coder?.qualityGates).toEqual(['Global persona gate']);
manager.save(loaded);
GlobalConfigManager.resetInstance();
const reloadedManager = GlobalConfigManager.getInstance();
const reloaded = reloadedManager.load();
const reloadedPieceOverrides = reloaded.pieceOverrides as unknown as {
personas?: Record<string, { qualityGates?: string[] }>;
};
expect(reloadedPieceOverrides.personas?.coder?.qualityGates).toEqual(['Global persona gate']);
});
it('should preserve empty quality_gates array in personas', () => {
const configContent = `
piece_overrides:
personas:
coder:
quality_gates: []
`;
writeFileSync(testConfigPath, configContent, 'utf-8');
const manager = GlobalConfigManager.getInstance();
const loaded = manager.load();
const loadedPieceOverrides = loaded.pieceOverrides as unknown as {
personas?: Record<string, { qualityGates?: string[] }>;
};
expect(loadedPieceOverrides.personas?.coder?.qualityGates).toEqual([]);
manager.save(loaded);
GlobalConfigManager.resetInstance();
const reloadedManager = GlobalConfigManager.getInstance();
const reloaded = reloadedManager.load();
const reloadedPieceOverrides = reloaded.pieceOverrides as unknown as {
personas?: Record<string, { qualityGates?: string[] }>;
};
expect(reloadedPieceOverrides.personas?.coder?.qualityGates).toEqual([]);
});
});
describe('security hardening', () => {

View File

@ -10,25 +10,20 @@ const projectDir = join(rootDir, 'project');
vi.mock('../infra/config/global/globalConfig.js', async (importOriginal) => {
const original = await importOriginal() as Record<string, unknown>;
const globalMigratedValues = {
logLevel: 'info',
pipeline: { defaultBranchPrefix: 'global/' },
personaProviders: { coder: { provider: 'claude', model: 'claude-3-5-sonnet-latest' } },
branchNameStrategy: 'ai',
minimalOutput: false,
concurrency: 2,
taskPollIntervalMs: 2000,
interactivePreviewMovements: 4,
verbose: false,
} as const;
return {
...original,
loadGlobalConfig: () => ({
language: 'en',
provider: 'claude',
autoFetch: false,
pipeline: { defaultBranchPrefix: 'global/' },
personaProviders: { coder: { provider: 'claude', model: 'claude-3-5-sonnet-latest' } },
branchNameStrategy: 'ai',
minimalOutput: false,
concurrency: 2,
taskPollIntervalMs: 2000,
interactivePreviewMovements: 4,
}),
loadGlobalMigratedProjectLocalFallback: () => globalMigratedValues,
invalidateGlobalConfigCache: () => undefined,
};
});
@ -40,7 +35,7 @@ const {
invalidateGlobalConfigCache,
} = 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(() => {
mkdirSync(projectDir, { recursive: true });
mkdirSync(join(projectDir, '.takt'), { recursive: true });
@ -48,7 +43,6 @@ describe('IT: migrated config keys should prefer project over global', () => {
writeFileSync(
join(projectDir, '.takt', 'config.yaml'),
[
'log_level: debug',
'pipeline:',
' default_branch_prefix: "project/"',
'persona_providers:',
@ -60,7 +54,6 @@ describe('IT: migrated config keys should prefer project over global', () => {
'concurrency: 5',
'task_poll_interval_ms: 1300',
'interactive_preview_movements: 1',
'verbose: true',
].join('\n'),
'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, [
'logLevel',
'pipeline',
'personaProviders',
'branchNameStrategy',
@ -87,10 +79,8 @@ describe('IT: migrated config keys should prefer project over global', () => {
'concurrency',
'taskPollIntervalMs',
'interactivePreviewMovements',
'verbose',
]);
expect(resolved.logLevel).toBe('debug');
expect(resolved.pipeline).toEqual({
defaultBranchPrefix: 'project/',
});
@ -102,10 +92,9 @@ describe('IT: migrated config keys should prefer project over global', () => {
expect(resolved.concurrency).toBe(5);
expect(resolved.taskPollIntervalMs).toBe(1300);
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(
join(projectDir, '.takt', 'config.yaml'),
'',
@ -115,7 +104,6 @@ describe('IT: migrated config keys should prefer project over global', () => {
invalidateAllResolvedConfigCache();
const resolved = resolveConfigValues(projectDir, [
'logLevel',
'pipeline',
'personaProviders',
'branchNameStrategy',
@ -123,10 +111,8 @@ describe('IT: migrated config keys should prefer project over global', () => {
'concurrency',
'taskPollIntervalMs',
'interactivePreviewMovements',
'verbose',
]);
expect(resolved.logLevel).toBe('info');
expect(resolved.pipeline).toEqual({ defaultBranchPrefix: 'global/' });
expect(resolved.personaProviders).toEqual({
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.taskPollIntervalMs).toBe(2000);
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(
join(projectDir, '.takt', 'config.yaml'),
'',
@ -148,8 +133,8 @@ describe('IT: migrated config keys should prefer project over global', () => {
invalidateGlobalConfigCache();
invalidateAllResolvedConfigCache();
expect(resolveConfigValueWithSource(projectDir, 'logLevel')).toEqual({
value: 'info',
expect(resolveConfigValueWithSource(projectDir, 'pipeline')).toEqual({
value: { defaultBranchPrefix: 'global/' },
source: 'global',
});
});

View File

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

View File

@ -14,6 +14,7 @@ import {
resetScenario,
type ScenarioEntry,
} from '../infra/mock/index.js';
import { STATUS_VALUES } from '../core/models/status.js';
describe('ScenarioQueue', () => {
it('should consume entries in order when no agent specified', () => {
@ -130,6 +131,16 @@ describe('loadScenarioFile', () => {
expect(entries[1]).toEqual({ persona: undefined, status: 'blocked', content: 'Blocked' });
});
it('should accept all statuses from shared status contract', () => {
const scenario = STATUS_VALUES.map((status, i) => ({ status, content: `entry-${i}` }));
const filePath = join(tempDir, 'all-statuses.json');
writeFileSync(filePath, JSON.stringify(scenario));
const entries = loadScenarioFile(filePath);
expect(entries.map((entry) => entry.status)).toEqual([...STATUS_VALUES]);
});
it('should default status to "done" if omitted', () => {
const scenario = [{ content: 'Simple response' }];
const filePath = join(tempDir, 'scenario.json');
@ -167,7 +178,21 @@ describe('loadScenarioFile', () => {
it('should throw for invalid status', () => {
const filePath = join(tempDir, 'bad-status.json');
writeFileSync(filePath, '[{"content": "test", "status": "invalid"}]');
writeFileSync(filePath, '[{"content": "test", "status": "approved"}]');
expect(() => loadScenarioFile(filePath)).toThrow('invalid status');
});
it('should throw for rejected status', () => {
const filePath = join(tempDir, 'rejected-status.json');
writeFileSync(filePath, '[{"content": "test", "status": "rejected"}]');
expect(() => loadScenarioFile(filePath)).toThrow('invalid status');
});
it('should throw for improve status', () => {
const filePath = join(tempDir, 'improve-status.json');
writeFileSync(filePath, '[{"content": "test", "status": "improve"}]');
expect(() => loadScenarioFile(filePath)).toThrow('invalid status');
});

View File

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

View File

@ -14,6 +14,7 @@ import {
GlobalConfigSchema,
ProjectConfigSchema,
} from '../core/models/index.js';
import { STATUS_VALUES } from '../core/models/status.js';
describe('AgentTypeSchema', () => {
it('should accept valid agent types', () => {
@ -30,18 +31,25 @@ describe('AgentTypeSchema', () => {
describe('StatusSchema', () => {
it('should accept valid statuses', () => {
expect(StatusSchema.parse('pending')).toBe('pending');
expect(StatusSchema.parse('done')).toBe('done');
expect(StatusSchema.parse('approved')).toBe('approved');
expect(StatusSchema.parse('rejected')).toBe('rejected');
expect(StatusSchema.parse('blocked')).toBe('blocked');
expect(StatusSchema.parse('error')).toBe('error');
expect(StatusSchema.parse('answer')).toBe('answer');
});
it('should align with the shared status contract values', () => {
expect(StatusSchema.options).toEqual([...STATUS_VALUES]);
});
it('should reject invalid statuses', () => {
expect(() => StatusSchema.parse('unknown')).toThrow();
expect(() => StatusSchema.parse('conditional')).toThrow();
expect(() => StatusSchema.parse('pending')).toThrow();
expect(() => StatusSchema.parse('approved')).toThrow();
expect(() => StatusSchema.parse('rejected')).toThrow();
expect(() => StatusSchema.parse('improve')).toThrow();
expect(() => StatusSchema.parse('cancelled')).toThrow();
expect(() => StatusSchema.parse('interrupted')).toThrow();
expect(() => StatusSchema.parse('answer')).toThrow();
});
});

View File

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

View File

@ -95,13 +95,63 @@ piece_overrides:
expect(reloaded.pieceOverrides?.qualityGates).toEqual(['Test 1', 'Test 2']);
});
it('should preserve personas quality_gates in save/load cycle', () => {
const configPath = join(testDir, '.takt', 'config.yaml');
const configContent = `
piece_overrides:
personas:
coder:
quality_gates:
- "Project persona gate"
`;
writeFileSync(configPath, configContent, 'utf-8');
const loaded = loadProjectConfig(testDir);
const loadedPieceOverrides = loaded.pieceOverrides as unknown as {
personas?: Record<string, { qualityGates?: string[] }>;
};
expect(loadedPieceOverrides.personas?.coder?.qualityGates).toEqual(['Project persona gate']);
saveProjectConfig(testDir, loaded);
const reloaded = loadProjectConfig(testDir);
const reloadedPieceOverrides = reloaded.pieceOverrides as unknown as {
personas?: Record<string, { qualityGates?: string[] }>;
};
expect(reloadedPieceOverrides.personas?.coder?.qualityGates).toEqual(['Project persona gate']);
});
it('should preserve empty quality_gates array in personas', () => {
const configPath = join(testDir, '.takt', 'config.yaml');
const configContent = `
piece_overrides:
personas:
coder:
quality_gates: []
`;
writeFileSync(configPath, configContent, 'utf-8');
const loaded = loadProjectConfig(testDir);
const loadedPieceOverrides = loaded.pieceOverrides as unknown as {
personas?: Record<string, { qualityGates?: string[] }>;
};
expect(loadedPieceOverrides.personas?.coder?.qualityGates).toEqual([]);
saveProjectConfig(testDir, loaded);
const reloaded = loadProjectConfig(testDir);
const reloadedPieceOverrides = reloaded.pieceOverrides as unknown as {
personas?: Record<string, { qualityGates?: string[] }>;
};
expect(reloadedPieceOverrides.personas?.coder?.qualityGates).toEqual([]);
});
});
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 configContent = [
'log_level: debug',
'pipeline:',
' default_branch_prefix: "proj/"',
' commit_message_template: "feat: {title} (#{issue})"',
@ -114,12 +164,10 @@ piece_overrides:
'concurrency: 3',
'task_poll_interval_ms: 1200',
'interactive_preview_movements: 2',
'verbose: true',
].join('\n');
writeFileSync(configPath, configContent, 'utf-8');
const loaded = loadProjectConfig(testDir) as Record<string, unknown>;
expect(loaded.logLevel).toBe('debug');
const loaded = loadProjectConfig(testDir);
expect(loaded.pipeline).toEqual({
defaultBranchPrefix: 'proj/',
commitMessageTemplate: 'feat: {title} (#{issue})',
@ -132,12 +180,10 @@ piece_overrides:
expect(loaded.concurrency).toBe(3);
expect(loaded.taskPollIntervalMs).toBe(1200);
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 = {
logLevel: 'warn',
pipeline: {
defaultBranchPrefix: 'task/',
prBodyTemplate: 'Body {report}',
@ -150,13 +196,11 @@ piece_overrides:
concurrency: 4,
taskPollIntervalMs: 1500,
interactivePreviewMovements: 1,
verbose: false,
} as ProjectLocalConfig;
saveProjectConfig(testDir, config);
const raw = readFileSync(join(testDir, '.takt', 'config.yaml'), 'utf-8');
expect(raw).toContain('log_level: warn');
expect(raw).toContain('pipeline:');
expect(raw).toContain('default_branch_prefix: task/');
expect(raw).toContain('pr_body_template: Body {report}');
@ -167,20 +211,46 @@ piece_overrides:
expect(raw).toContain('concurrency: 4');
expect(raw).toContain('task_poll_interval_ms: 1500');
expect(raw).toContain('interactive_preview_movements: 1');
expect(raw).not.toContain('verbose: false');
});
it('should not persist schema-injected default values on save', () => {
it('should not persist empty pipeline object on save', () => {
// Given: empty pipeline object
const config = {
pipeline: {},
} as ProjectLocalConfig;
// When: project config is saved
saveProjectConfig(testDir, config);
// Then: pipeline key is not serialized
const raw = readFileSync(join(testDir, '.takt', 'config.yaml'), 'utf-8');
expect(raw).not.toContain('pipeline:');
});
it('should not persist empty personaProviders object on save', () => {
// Given: empty personaProviders object
const config = {
personaProviders: {},
} as ProjectLocalConfig;
// When: project config is saved
saveProjectConfig(testDir, config);
// Then: persona_providers key is not serialized
const raw = readFileSync(join(testDir, '.takt', 'config.yaml'), 'utf-8');
expect(raw).not.toContain('persona_providers:');
expect(raw).not.toContain('personaProviders:');
});
it('should not persist unset values on save', () => {
const loaded = loadProjectConfig(testDir);
saveProjectConfig(testDir, loaded);
const raw = readFileSync(join(testDir, '.takt', 'config.yaml'), 'utf-8');
expect(raw).not.toContain('log_level: info');
expect(raw).not.toContain('minimal_output: false');
expect(raw).not.toContain('concurrency: 1');
expect(raw).not.toContain('task_poll_interval_ms: 500');
expect(raw).not.toContain('interactive_preview_movements: 3');
expect(raw).not.toContain('verbose: false');
expect(raw).not.toContain('minimal_output:');
expect(raw).not.toContain('concurrency:');
expect(raw).not.toContain('task_poll_interval_ms:');
expect(raw).not.toContain('interactive_preview_movements:');
});
it('should fail fast when project config contains global-only cli path keys', () => {

View File

@ -4,23 +4,36 @@
import { describe, it, expect } from 'vitest';
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 = [
string,
string[] | undefined,
boolean | undefined,
string | undefined,
PieceOverrides | undefined,
PieceOverrides | undefined,
];
function applyOverrides(...args: ApplyOverridesArgs): string[] | undefined {
return applyQualityGateOverrides(...args);
}
describe('applyQualityGateOverrides', () => {
it('returns undefined when no gates are defined', () => {
const result = applyQualityGateOverrides('implement', undefined, true, undefined, undefined);
const result = applyOverrides('implement', undefined, true, undefined, undefined, undefined);
expect(result).toBeUndefined();
});
it('returns YAML gates when no overrides are defined', () => {
const yamlGates = ['Test passes'];
const result = applyQualityGateOverrides('implement', yamlGates, true, undefined, undefined);
const result = applyOverrides('implement', yamlGates, true, undefined, undefined, undefined);
expect(result).toEqual(['Test passes']);
});
it('returns empty array when yamlGates is empty array and no overrides', () => {
const yamlGates: string[] = [];
const result = applyQualityGateOverrides('implement', yamlGates, true, undefined, undefined);
const result = applyOverrides('implement', yamlGates, true, undefined, undefined, undefined);
expect(result).toEqual([]);
});
@ -29,7 +42,7 @@ describe('applyQualityGateOverrides', () => {
const globalOverrides: PieceOverrides = {
qualityGates: ['E2E tests pass'],
};
const result = applyQualityGateOverrides('implement', yamlGates, true, undefined, globalOverrides);
const result = applyOverrides('implement', yamlGates, true, undefined, undefined, globalOverrides);
expect(result).toEqual(['E2E tests pass', 'Unit tests pass']);
});
@ -43,7 +56,7 @@ describe('applyQualityGateOverrides', () => {
},
},
};
const result = applyQualityGateOverrides('implement', yamlGates, true, undefined, globalOverrides);
const result = applyOverrides('implement', yamlGates, true, undefined, undefined, globalOverrides);
expect(result).toEqual(['Global gate', 'Movement-specific gate', 'Unit tests pass']);
});
@ -55,7 +68,7 @@ describe('applyQualityGateOverrides', () => {
const projectOverrides: PieceOverrides = {
qualityGates: ['Project gate'],
};
const result = applyQualityGateOverrides('implement', yamlGates, true, projectOverrides, globalOverrides);
const result = applyOverrides('implement', yamlGates, true, undefined, projectOverrides, globalOverrides);
expect(result).toEqual(['Global gate', 'Project gate', 'YAML gate']);
});
@ -68,7 +81,7 @@ describe('applyQualityGateOverrides', () => {
},
},
};
const result = applyQualityGateOverrides('implement', yamlGates, true, projectOverrides, undefined);
const result = applyOverrides('implement', yamlGates, true, undefined, projectOverrides, undefined);
expect(result).toEqual(['Project movement gate', 'YAML gate']);
});
@ -78,7 +91,7 @@ describe('applyQualityGateOverrides', () => {
qualityGates: ['Global gate'],
qualityGatesEditOnly: true,
};
const result = applyQualityGateOverrides('review', yamlGates, false, undefined, globalOverrides);
const result = applyOverrides('review', yamlGates, false, undefined, undefined, globalOverrides);
expect(result).toEqual(['YAML gate']); // Global gate excluded because edit=false
});
@ -88,7 +101,7 @@ describe('applyQualityGateOverrides', () => {
qualityGates: ['Global gate'],
qualityGatesEditOnly: true,
};
const result = applyQualityGateOverrides('implement', yamlGates, true, undefined, globalOverrides);
const result = applyOverrides('implement', yamlGates, true, undefined, undefined, globalOverrides);
expect(result).toEqual(['Global gate', 'YAML gate']);
});
@ -98,7 +111,7 @@ describe('applyQualityGateOverrides', () => {
qualityGates: ['Project gate'],
qualityGatesEditOnly: true,
};
const result = applyQualityGateOverrides('review', yamlGates, false, projectOverrides, undefined);
const result = applyOverrides('review', yamlGates, false, undefined, projectOverrides, undefined);
expect(result).toEqual(['YAML gate']); // Project gate excluded because edit=false
});
@ -113,7 +126,7 @@ describe('applyQualityGateOverrides', () => {
},
},
};
const result = applyQualityGateOverrides('review', yamlGates, false, projectOverrides, undefined);
const result = applyOverrides('review', yamlGates, false, undefined, projectOverrides, undefined);
// Project global gate excluded (edit=false), but movement-specific gate included
expect(result).toEqual(['Review-specific gate', 'YAML gate']);
});
@ -136,7 +149,7 @@ describe('applyQualityGateOverrides', () => {
},
},
};
const result = applyQualityGateOverrides('implement', yamlGates, true, projectOverrides, globalOverrides);
const result = applyOverrides('implement', yamlGates, true, undefined, projectOverrides, globalOverrides);
expect(result).toEqual([
'Global gate',
'Global movement gate',
@ -155,10 +168,104 @@ describe('applyQualityGateOverrides', () => {
},
},
};
const result = applyQualityGateOverrides('implement', yamlGates, true, projectOverrides, undefined);
const result = applyOverrides('implement', yamlGates, true, undefined, projectOverrides, undefined);
expect(result).toEqual(['YAML gate']); // No override for 'implement', only for 'review'
});
describe('persona overrides', () => {
it('applies persona-specific gates from global and project configs in order', () => {
// Given: both global and project configs define gates for the same persona
const yamlGates = ['YAML gate'];
const globalOverrides = {
personas: {
coder: {
qualityGates: ['Global persona gate'],
},
},
} as PieceOverrides;
const projectOverrides = {
personas: {
coder: {
qualityGates: ['Project persona gate'],
},
},
} as PieceOverrides;
// When: the movement is executed with the matching persona
const result = applyOverrides('implement', yamlGates, true, 'coder', projectOverrides, globalOverrides);
// Then: gates are additive with global persona gates before project persona gates
expect(result).toEqual(['Global persona gate', 'Project persona gate', 'YAML gate']);
});
it('does not apply persona-specific gates when persona does not match', () => {
// Given: config defines gates for reviewer persona only
const yamlGates = ['YAML gate'];
const projectOverrides = {
personas: {
reviewer: {
qualityGates: ['Reviewer persona gate'],
},
},
} as PieceOverrides;
// When: movement persona is coder
const result = applyOverrides('implement', yamlGates, true, 'coder', projectOverrides, undefined);
// Then: only YAML gates remain
expect(result).toEqual(['YAML gate']);
});
it('deduplicates gates across movement, persona, and YAML sources', () => {
// Given: same gate appears in multiple override layers
const yamlGates = ['Shared gate', 'YAML only'];
const globalOverrides = {
movements: {
implement: {
qualityGates: ['Shared gate', 'Global movement only'],
},
},
personas: {
coder: {
qualityGates: ['Shared gate', 'Global persona only'],
},
},
} as PieceOverrides;
const projectOverrides = {
personas: {
coder: {
qualityGates: ['Shared gate', 'Project persona only'],
},
},
} as PieceOverrides;
// When: overrides are merged for matching movement + persona
const result = applyOverrides('implement', yamlGates, true, 'coder', projectOverrides, globalOverrides);
// Then: duplicates are removed, first appearance order is preserved
expect(result).toEqual([
'Shared gate',
'Global movement only',
'Global persona only',
'Project persona only',
'YAML only',
]);
});
it('throws when personaName is empty', () => {
const projectOverrides = {
personas: {
coder: {
qualityGates: ['Project persona gate'],
},
},
} as PieceOverrides;
expect(() =>
applyOverrides('implement', ['YAML gate'], true, ' ', projectOverrides, undefined)
).toThrow('Invalid persona name for movement "implement": empty value');
});
});
describe('deduplication', () => {
it('removes duplicate gates from multiple sources', () => {
const yamlGates = ['Test 1', 'Test 2'];
@ -168,7 +275,7 @@ describe('applyQualityGateOverrides', () => {
const projectOverrides: PieceOverrides = {
qualityGates: ['Test 1', 'Test 4'],
};
const result = applyQualityGateOverrides('implement', yamlGates, true, projectOverrides, globalOverrides);
const result = applyOverrides('implement', yamlGates, true, undefined, projectOverrides, globalOverrides);
// Duplicates removed: Test 1, Test 2 appear only once
expect(result).toEqual(['Test 2', 'Test 3', 'Test 1', 'Test 4']);
});
@ -177,7 +284,7 @@ describe('applyQualityGateOverrides', () => {
const projectOverrides: PieceOverrides = {
qualityGates: ['Test 1', 'Test 2', 'Test 1', 'Test 3', 'Test 2'],
};
const result = applyQualityGateOverrides('implement', undefined, true, projectOverrides, undefined);
const result = applyOverrides('implement', undefined, true, undefined, projectOverrides, undefined);
expect(result).toEqual(['Test 1', 'Test 2', 'Test 3']);
});
@ -186,7 +293,7 @@ describe('applyQualityGateOverrides', () => {
const projectOverrides: PieceOverrides = {
qualityGates: ['npm run test', 'npm run build'],
};
const result = applyQualityGateOverrides('implement', yamlGates, true, projectOverrides, undefined);
const result = applyOverrides('implement', yamlGates, true, undefined, projectOverrides, undefined);
// 'npm run test' appears only once
expect(result).toEqual(['npm run test', 'npm run build', 'npm run lint']);
});

View File

@ -36,14 +36,10 @@ describe('resetGlobalConfigToTemplate', () => {
const newConfig = readFileSync(configPath, 'utf-8');
expect(newConfig).toContain('# TAKT グローバル設定サンプル');
expect(newConfig).toContain('language: ja');
expect(newConfig).not.toContain('provider:');
expect(newConfig).not.toContain('runtime:');
expect(newConfig).not.toContain('branch_name_strategy:');
expect(newConfig).not.toContain('concurrency:');
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:');
// Template should only have 'language' as an active (non-commented) setting
const activeLines = newConfig.split('\n').filter(line => !line.startsWith('#') && line.trim() !== '');
expect(activeLines.length).toBe(1);
expect(activeLines[0]).toMatch(/^language: ja/);
});
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');
expect(newConfig).toContain('# TAKT global configuration sample');
expect(newConfig).toContain('language: en');
expect(newConfig).not.toContain('provider:');
expect(newConfig).not.toContain('runtime:');
expect(newConfig).not.toContain('branch_name_strategy:');
expect(newConfig).not.toContain('concurrency:');
const activeLines = newConfig.split('\n').filter(line => !line.startsWith('#') && line.trim() !== '');
expect(activeLines.length).toBe(1);
expect(activeLines[0]).toMatch(/^language: en/);
});
});

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
* RESOLUTION_REGISTRY defaultValue but instead use schema defaults
* or other guaranteed sources.
* Verifies that keys with PROJECT_LOCAL_DEFAULTS resolve correctly
* and that project config takes priority over global config.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
@ -33,11 +32,9 @@ const {
} = await import('../infra/config/resolveConfigValue.js');
const { invalidateGlobalConfigCache } = await import('../infra/config/global/globalConfig.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;
describe('RESOLUTION_REGISTRY defaultValue removal', () => {
describe('config resolution defaults and project-local priority', () => {
let projectDir: string;
beforeEach(() => {
@ -57,68 +54,8 @@ describe('RESOLUTION_REGISTRY defaultValue removal', () => {
}
});
describe('verbose', () => {
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', () => {
describe('project-local priority', () => {
it.each([
{
key: 'logLevel',
projectYaml: 'log_level: debug\n',
expected: 'debug',
},
{
key: 'minimalOutput',
projectYaml: 'minimal_output: true\n',
@ -144,11 +81,6 @@ describe('RESOLUTION_REGISTRY defaultValue removal', () => {
projectYaml: 'concurrency: 3\n',
expected: 3,
},
{
key: 'verbose',
projectYaml: 'verbose: true\n',
expected: true,
},
])('should resolve $key from project config', ({ key, projectYaml, expected }) => {
writeFileSync(globalConfigPath, 'language: en\n', 'utf-8');
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);
mkdirSync(configDir, { recursive: true });
writeFileSync(join(configDir, 'config.yaml'), 'provider: claude\n', 'utf-8');
writeFileSync(
globalConfigPath,
['language: en'].join('\n'),
'utf-8',
);
writeFileSync(globalConfigPath, 'language: en\n', 'utf-8');
invalidateGlobalConfigCache();
const pipelineResult = resolveConfigValueWithSource(projectDir, 'pipeline' as ConfigParameterKey);
const personaResult = resolveConfigValueWithSource(projectDir, 'personaProviders' as ConfigParameterKey);
const branchStrategyResult = resolveConfigValueWithSource(projectDir, 'branchNameStrategy' as ConfigParameterKey);
expect(pipelineResult).toEqual({
value: undefined,
source: 'default',
});
expect(personaResult).toEqual({
value: undefined,
source: 'default',
});
expect(branchStrategyResult).toEqual({
value: undefined,
source: 'default',
});
expect(pipelineResult).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);
mkdirSync(configDir, { recursive: true });
writeFileSync(join(configDir, 'config.yaml'), 'provider: claude\n', 'utf-8');
writeFileSync(
globalConfigPath,
['language: en'].join('\n'),
'utf-8',
);
writeFileSync(globalConfigPath, 'language: en\n', 'utf-8');
invalidateGlobalConfigCache();
expect(resolveConfigValueWithSource(projectDir, 'logLevel')).toEqual({ value: 'info', source: 'default' });
expect(resolveConfigValueWithSource(projectDir, 'minimalOutput')).toEqual({ value: false, source: 'default' });
expect(resolveConfigValueWithSource(projectDir, 'concurrency')).toEqual({ value: 1, source: 'default' });
expect(resolveConfigValueWithSource(projectDir, 'taskPollIntervalMs')).toEqual({ value: 500, 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(
globalConfigPath,
[
'language: en',
'log_level: warn',
'pipeline:',
' default_branch_prefix: "legacy/"',
' default_branch_prefix: "global/"',
'persona_providers:',
' coder:',
' provider: codex',
' model: gpt-5',
'branch_name_strategy: ai',
'minimal_output: true',
'verbose: true',
'concurrency: 3',
'task_poll_interval_ms: 1200',
'interactive_preview_movements: 2',
@ -283,9 +195,8 @@ describe('RESOLUTION_REGISTRY defaultValue removal', () => {
);
invalidateGlobalConfigCache();
expect(resolveConfigValueWithSource(projectDir, 'logLevel')).toEqual({ value: 'warn', source: 'global' });
expect(resolveConfigValueWithSource(projectDir, 'pipeline')).toEqual({
value: { defaultBranchPrefix: 'legacy/' },
value: { defaultBranchPrefix: 'global/' },
source: 'global',
});
expect(resolveConfigValueWithSource(projectDir, 'personaProviders')).toEqual({
@ -297,7 +208,6 @@ describe('RESOLUTION_REGISTRY defaultValue removal', () => {
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, 'taskPollIntervalMs')).toEqual({ value: 1200, source: 'global' });
expect(resolveConfigValueWithSource(projectDir, 'interactivePreviewMovements')).toEqual({
@ -305,60 +215,6 @@ describe('RESOLUTION_REGISTRY defaultValue removal', () => {
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', () => {

View File

@ -210,15 +210,7 @@ describe('instructBranch direct execution flow', () => {
expect(originalTaskInfo.data.piece).toBe('original-piece');
});
it('should reuse previous piece when confirmed', async () => {
mockFindRunForTask.mockReturnValue('run-previous');
mockLoadRunSessionContext.mockReturnValue({
task: 'done',
piece: 'default',
status: 'completed',
movementLogs: [],
reports: [],
});
it('should reuse previous piece from task data when confirmed', async () => {
mockConfirm
.mockResolvedValueOnce(true);
@ -230,7 +222,7 @@ describe('instructBranch direct execution flow', () => {
content: 'done',
branch: 'takt/done-task',
worktreePath: '/project/.takt/worktrees/done-task',
data: { task: 'done' },
data: { task: 'done', piece: 'default' },
});
expect(mockSelectPiece).not.toHaveBeenCalled();
@ -240,14 +232,6 @@ describe('instructBranch direct execution flow', () => {
});
it('should call selectPiece when previous piece reuse is declined', async () => {
mockFindRunForTask.mockReturnValue('run-previous');
mockLoadRunSessionContext.mockReturnValue({
task: 'done',
piece: 'default',
status: 'completed',
movementLogs: [],
reports: [],
});
mockConfirm
.mockResolvedValueOnce(false);
mockSelectPiece.mockResolvedValue('selected-piece');
@ -260,22 +244,14 @@ describe('instructBranch direct execution flow', () => {
content: 'done',
branch: 'takt/done-task',
worktreePath: '/project/.takt/worktrees/done-task',
data: { task: 'done' },
data: { task: 'done', piece: 'default' },
});
expect(mockSelectPiece).toHaveBeenCalledWith('/project');
expect(mockStartReExecution).toHaveBeenCalled();
});
it('should ignore previous piece when run metadata contains piece path', async () => {
mockFindRunForTask.mockReturnValue('run-previous');
mockLoadRunSessionContext.mockReturnValue({
task: 'done',
piece: '../secrets.yaml',
status: 'completed',
movementLogs: [],
reports: [],
});
it('should skip reuse prompt when task data has no piece', async () => {
mockSelectPiece.mockResolvedValue('selected-piece');
await instructBranch('/project', {
@ -291,18 +267,9 @@ describe('instructBranch direct execution flow', () => {
expect(mockConfirm).not.toHaveBeenCalled();
expect(mockSelectPiece).toHaveBeenCalledWith('/project');
expect(mockStartReExecution).toHaveBeenCalled();
});
it('should return false when replacement piece selection is cancelled after declining reuse', async () => {
mockFindRunForTask.mockReturnValue('run-previous');
mockLoadRunSessionContext.mockReturnValue({
task: 'done',
piece: 'default',
status: 'completed',
movementLogs: [],
reports: [],
});
mockConfirm.mockResolvedValueOnce(false);
mockSelectPiece.mockResolvedValue(null);
@ -314,7 +281,7 @@ describe('instructBranch direct execution flow', () => {
content: 'done',
branch: 'takt/done-task',
worktreePath: '/project/.takt/worktrees/done-task',
data: { task: 'done' },
data: { task: 'done', piece: 'default' },
});
expect(result).toBe(false);

View File

@ -184,11 +184,12 @@ beforeEach(() => {
describe('retryFailedTask', () => {
it('should run retry mode in existing worktree and execute directly', async () => {
const task = makeFailedTask();
mockConfirm.mockResolvedValue(true);
const result = await retryFailedTask(task, '/project');
expect(result).toBe(true);
expect(mockSelectPiece).toHaveBeenCalledWith('/project');
expect(mockSelectPiece).not.toHaveBeenCalled();
expect(mockRunRetryMode).toHaveBeenCalledWith(
'/project/.takt/worktrees/my-task',
expect.objectContaining({
@ -201,6 +202,7 @@ describe('retryFailedTask', () => {
});
it('should execute with selected piece without mutating taskInfo', async () => {
mockConfirm.mockResolvedValue(false);
mockSelectPiece.mockResolvedValue('selected-piece');
const originalTaskInfo = {
name: 'my-task',
@ -319,6 +321,7 @@ describe('retryFailedTask', () => {
it('should return false when piece selection is cancelled', async () => {
const task = makeFailedTask();
mockConfirm.mockResolvedValue(false);
mockSelectPiece.mockResolvedValue(null);
const result = await retryFailedTask(task, '/project');
@ -358,11 +361,7 @@ describe('retryFailedTask', () => {
expect(mockRequeueTask).toHaveBeenCalledWith('my-task', ['failed'], undefined, '既存ノート\n\n追加指示A');
});
describe('when previous piece exists', () => {
beforeEach(() => {
mockFindRunForTask.mockReturnValue('run-123');
});
describe('when previous piece exists in task data', () => {
it('should ask whether to reuse previous piece with default yes', async () => {
const task = makeFailedTask();
@ -403,21 +402,13 @@ describe('retryFailedTask', () => {
expect(mockLoadPieceByIdentifier).not.toHaveBeenCalled();
});
it('should ignore previous piece when run metadata contains piece path', async () => {
const task = makeFailedTask();
mockLoadRunSessionContext.mockReturnValue({
task: 'Do something',
piece: '../secrets.yaml',
status: 'failed',
movementLogs: [],
reports: [],
});
it('should skip reuse prompt when task data has no piece', async () => {
const task = makeFailedTask({ data: { task: 'Do something' } });
await retryFailedTask(task, '/project');
expect(mockConfirm).not.toHaveBeenCalled();
expect(mockSelectPiece).toHaveBeenCalledWith('/project');
expect(mockLoadPieceByIdentifier).toHaveBeenCalledWith('default', '/project');
});
});
});

View File

@ -71,13 +71,13 @@ export async function runPreActionHook(): Promise<void> {
const verbose = isVerboseMode(resolvedCwd);
initDebugLogger(verbose ? { enabled: true } : undefined, resolvedCwd);
const config = resolveConfigValues(resolvedCwd, ['logLevel', 'minimalOutput']);
const config = resolveConfigValues(resolvedCwd, ['logging', 'minimalOutput']);
if (verbose) {
setVerboseConsole(true);
setLogLevel('debug');
} else {
setLogLevel(config.logLevel);
setLogLevel(config.logging?.level ?? 'info');
}
const quietMode = rootOpts.quiet === true || config.minimalOutput === true;

View File

@ -1,5 +1,10 @@
/**
* 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';
@ -23,6 +28,8 @@ export interface PieceOverrides {
qualityGatesEditOnly?: boolean;
/** Movement-specific quality gates overrides */
movements?: Record<string, MovementQualityGatesOverride>;
/** Persona-specific quality gates overrides */
personas?: Record<string, MovementQualityGatesOverride>;
}
/** Custom agent configuration */
@ -89,27 +96,65 @@ export interface NotificationSoundEventsConfig {
runAbort?: boolean;
}
/** Persisted global configuration for ~/.takt/config.yaml */
export interface PersistedGlobalConfig {
/**
* /
* ProjectConfig
* @globalOnly
*/
/**
* Project-level configuration stored in .takt/config.yaml.
*/
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 */
language: Language;
provider?: 'claude' | 'codex' | 'opencode' | 'cursor' | 'copilot' | 'mock';
model?: string;
/** @globalOnly */
logging?: LoggingConfig;
analytics?: AnalyticsConfig;
/** @globalOnly */
/** Directory for shared clones (worktree_dir in config). If empty, uses ../{clone-name} relative to project */
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 */
/** List of builtin piece/agent names to exclude from fallback loading */
disabledBuiltins?: string[];
@ -161,12 +206,6 @@ export interface PersistedGlobalConfig {
/** @globalOnly */
/** Path to piece categories file (default: ~/.takt/preferences/piece-categories.yaml) */
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 */
/** Prevent macOS idle sleep during takt execution using caffeinate (default: false) */
preventSleep?: boolean;
@ -179,45 +218,4 @@ export interface PersistedGlobalConfig {
/** @globalOnly */
/** Opt-in: fetch remote before cloning to keep clones up-to-date (default: false) */
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

@ -8,6 +8,7 @@ import { z } from 'zod/v4';
import { DEFAULT_LANGUAGE } from '../../shared/constants.js';
import { McpServersSchema } from './mcp-schemas.js';
import { INTERACTIVE_MODES } from './interactive-mode.js';
import { STATUS_VALUES } from './status.js';
export { McpServerConfigSchema, McpServersSchema } from './mcp-schemas.js';
@ -44,18 +45,7 @@ export const TaktConfigSchema = z.object({
export const AgentTypeSchema = z.enum(['coder', 'architect', 'supervisor', 'custom']);
/** Status schema */
export const StatusSchema = z.enum([
'pending',
'done',
'blocked',
'error',
'approved',
'rejected',
'improve',
'cancelled',
'interrupted',
'answer',
]);
export const StatusSchema = z.enum(STATUS_VALUES);
/** Permission mode schema for tool execution */
export const PermissionModeSchema = z.enum(['readonly', 'edit', 'full']);
@ -211,6 +201,8 @@ export const PieceOverridesSchema = z.object({
quality_gates_edit_only: z.boolean().optional(),
/** Movement-specific quality gates overrides */
movements: z.record(z.string(), MovementQualityGatesOverrideSchema).optional(),
/** Persona-specific quality gates overrides */
personas: z.record(z.string(), MovementQualityGatesOverrideSchema).optional(),
}).optional();
/** Rule-based transition schema (new unified format) */
@ -500,83 +492,8 @@ export const PieceCategoryConfigNodeSchema: z.ZodType<PieceCategoryConfigNode> =
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 */
export const ProjectConfigSchema = z.object({
log_level: z.enum(['debug', 'info', 'warn', 'error']).optional(),
verbose: z.boolean().optional(),
provider: ProviderReferenceSchema.optional(),
model: z.string().optional(),
analytics: AnalyticsConfigSchema.optional(),
@ -614,3 +531,71 @@ export const ProjectConfigSchema = z.object({
/** Compatibility flag for full submodule acquisition when submodules is unset */
with_submodules: z.boolean().optional(),
}).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

@ -5,6 +5,8 @@
import type { AgentResponse } from './response.js';
import type { Status } from './status.js';
type SessionAgentStatus = 'pending' | Status;
/**
* Session state for piece execution
*/
@ -13,9 +15,9 @@ export interface SessionState {
projectDir: string;
iteration: number;
maxMovements: number;
coderStatus: Status;
architectStatus: Status;
supervisorStatus: Status;
coderStatus: SessionAgentStatus;
architectStatus: SessionAgentStatus;
supervisorStatus: SessionAgentStatus;
history: AgentResponse[];
context: string;
}

View File

@ -6,17 +6,10 @@
export type AgentType = 'coder' | 'architect' | 'supervisor' | 'custom';
/** Execution status for agents and pieces */
export type Status =
| 'pending'
| 'done'
| 'blocked'
| 'error'
| 'approved'
| 'rejected'
| 'improve'
| 'cancelled'
| 'interrupted'
| 'answer';
export const STATUS_VALUES = ['done', 'blocked', 'error'] as const;
/** Execution status for agents and pieces */
export type Status = typeof STATUS_VALUES[number];
/** How a rule match was detected */
export type RuleMatchMethod =

View File

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

View File

@ -19,9 +19,10 @@ import { executeAgent } from '../../../agents/agent-usecases.js';
import { InstructionBuilder } from '../instruction/InstructionBuilder.js';
import { needsStatusJudgmentPhase, runReportPhase, runStatusJudgmentPhase } from '../phase-runner.js';
import { detectMatchedRule } from '../evaluation/index.js';
import type { StatusJudgmentPhaseResult } from '../phase-runner.js';
import { buildSessionKey } from '../session-key.js';
import { incrementMovementIteration, getPreviousOutput } from './state-manager.js';
import { createLogger } from '../../../shared/utils/index.js';
import { createLogger, getErrorMessage } from '../../../shared/utils/index.js';
import type { OptionsBuilder } from './OptionsBuilder.js';
import type { RunPaths } from '../run/run-paths.js';
@ -237,9 +238,17 @@ export class MovementExecutor {
}
// Phase 3: status judgment (new session, no tools, determines matched rule)
const phase3Result = needsStatusJudgmentPhase(step)
? await runStatusJudgmentPhase(step, phaseCtx)
: undefined;
let phase3Result: StatusJudgmentPhaseResult | undefined;
try {
phase3Result = needsStatusJudgmentPhase(step)
? await runStatusJudgmentPhase(step, phaseCtx)
: undefined;
} catch (error) {
log.info('Phase 3 status judgment failed, falling back to phase1 rule evaluation', {
movement: step.name,
error: getErrorMessage(error),
});
}
if (phase3Result) {
log.debug('Rule matched (Phase 3)', {

View File

@ -14,6 +14,7 @@ import { executeAgent } from '../../../agents/agent-usecases.js';
import { ParallelLogger } from './parallel-logger.js';
import { needsStatusJudgmentPhase, runReportPhase, runStatusJudgmentPhase } from '../phase-runner.js';
import { detectMatchedRule } from '../evaluation/index.js';
import type { StatusJudgmentPhaseResult } from '../phase-runner.js';
import { incrementMovementIteration } from './state-manager.js';
import { createLogger, getErrorMessage } from '../../../shared/utils/index.js';
import { buildSessionKey } from '../session-key.js';
@ -154,9 +155,17 @@ export class ParallelRunner {
}
// Phase 3: status judgment for sub-movement
const subPhase3 = needsStatusJudgmentPhase(subMovement)
? await runStatusJudgmentPhase(subMovement, phaseCtx)
: undefined;
let subPhase3: StatusJudgmentPhaseResult | undefined;
try {
subPhase3 = needsStatusJudgmentPhase(subMovement)
? await runStatusJudgmentPhase(subMovement, phaseCtx)
: undefined;
} catch (error) {
log.info('Phase 3 status judgment failed for sub-movement, falling back to phase1 rule evaluation', {
movement: subMovement.name,
error: getErrorMessage(error),
});
}
let finalResponse: AgentResponse;
if (subPhase3) {

View File

@ -446,6 +446,14 @@ export class PieceEngine extends EventEmitter {
throw new Error(`No matching rule found for movement "${step.name}" (status: ${response.status})`);
}
private resolveNextMovementFromDone(step: PieceMovement, response: AgentResponse): string {
if (response.status !== 'done') {
throw new Error(`Unhandled response status: ${response.status}`);
}
return this.resolveNextMovement(step, response);
}
/** Build instruction (public, used by pieceExecution.ts for logging) */
buildInstruction(step: PieceMovement, movementIteration: number): string {
return this.movementExecutor.buildInstruction(
@ -557,7 +565,7 @@ export class PieceEngine extends EventEmitter {
this.emit('movement:complete', judgeMovement, response, instruction);
// Resolve next movement from the judge's rules
const nextMovement = this.resolveNextMovement(judgeMovement, response);
const nextMovement = this.resolveNextMovementFromDone(judgeMovement, response);
log.info('Loop monitor judge decision', {
cycle: monitor.cycle,
@ -658,7 +666,7 @@ export class PieceEngine extends EventEmitter {
break;
}
let nextMovement = this.resolveNextMovement(movement, response);
let nextMovement = this.resolveNextMovementFromDone(movement, response);
log.debug('Movement transition', {
from: movement.name,
status: response.status,
@ -764,7 +772,21 @@ export class PieceEngine extends EventEmitter {
this.state.iteration++;
const { response } = await this.runMovement(movement);
const nextMovement = this.resolveNextMovement(movement, response);
if (response.status === 'blocked') {
this.state.status = 'aborted';
this.emit('piece:abort', this.state, 'Piece blocked and no user input provided');
return { response, nextMovement: ABORT_MOVEMENT, isComplete: true, loopDetected: loopCheck.isLoop };
}
if (response.status === 'error') {
const detail = response.error ?? response.content;
this.state.status = 'aborted';
this.emit('piece:abort', this.state, `Movement "${movement.name}" failed: ${detail}`);
return { response, nextMovement: ABORT_MOVEMENT, isComplete: true, loopDetected: loopCheck.isLoop };
}
const nextMovement = this.resolveNextMovementFromDone(movement, response);
const isComplete = nextMovement === COMPLETE_MOVEMENT || nextMovement === ABORT_MOVEMENT;
if (response.matchedRuleIndex != null && movement.rules) {

View File

@ -1,5 +1,5 @@
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';
export interface MovementProviderModelInput {

View File

@ -7,7 +7,7 @@
import type { PermissionResult, PermissionUpdate } from '@anthropic-ai/claude-agent-sdk';
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 { MovementProviderOptions } from '../models/piece-types.js';

View File

@ -3,7 +3,7 @@
*/
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 { MovementProviderOptions } from '../../../core/models/piece-types.js';
import type { ProviderType } from '../../../infra/providers/index.js';

View File

@ -99,7 +99,7 @@ export async function instructBranch(
const previousRunContext = matchedSlug
? loadRunSessionContext(worktreePath, matchedSlug)
: undefined;
const selectedPiece = await selectPieceWithOptionalReuse(projectDir, previousRunContext?.piece, lang);
const selectedPiece = await selectPieceWithOptionalReuse(projectDir, target.data?.piece, lang);
if (!selectedPiece) {
info('Cancelled');
return false;

View File

@ -136,7 +136,7 @@ export async function retryFailedTask(
const matchedSlug = findRunForTask(worktreePath, task.content);
const runInfo = matchedSlug ? buildRetryRunInfo(worktreePath, matchedSlug) : null;
const selectedPiece = await selectPieceWithOptionalReuse(projectDir, runInfo?.piece);
const selectedPiece = await selectPieceWithOptionalReuse(projectDir, task.data?.piece);
if (!selectedPiece) {
info('Cancelled');
return false;

View File

@ -25,9 +25,6 @@ export class ClaudeClient {
result: { success: boolean; interrupted?: boolean; content: string; fullContent?: string },
): Status {
if (!result.success) {
if (result.interrupted) {
return 'interrupted';
}
return 'error';
}
return 'done';

View File

@ -64,7 +64,6 @@ export async function executeClaudeCli(
export class ClaudeProcess {
private options: ClaudeSpawnOptions;
private currentSessionId?: string;
private interrupted = false;
constructor(options: ClaudeSpawnOptions) {
this.options = options;
@ -72,18 +71,13 @@ export class ClaudeProcess {
/** Execute a prompt */
async execute(prompt: string): Promise<ClaudeResult> {
this.interrupted = false;
const result = await executeClaudeCli(prompt, this.options);
this.currentSessionId = result.sessionId;
if (result.interrupted) {
this.interrupted = true;
}
return result;
}
/** Interrupt the running query */
kill(): void {
this.interrupted = true;
interruptCurrentProcess();
}
@ -96,9 +90,4 @@ export class ClaudeProcess {
getSessionId(): string | undefined {
return this.currentSessionId;
}
/** Check if query was interrupted */
wasInterrupted(): boolean {
return this.interrupted;
}
}

View File

@ -1,6 +1,6 @@
import type { MovementProviderOptions, PieceRuntimeConfig } from '../../core/models/piece-types.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';
export function normalizeRuntime(
@ -41,7 +41,12 @@ export function denormalizeProviderProfiles(
}
export function normalizePieceOverrides(
raw: { quality_gates?: string[]; quality_gates_edit_only?: boolean; movements?: Record<string, { quality_gates?: string[] }> } | undefined,
raw: {
quality_gates?: string[];
quality_gates_edit_only?: boolean;
movements?: Record<string, { quality_gates?: string[] }>;
personas?: Record<string, { quality_gates?: string[] }>;
} | undefined,
): PieceOverrides | undefined {
if (!raw) return undefined;
return {
@ -55,14 +60,32 @@ export function normalizePieceOverrides(
])
)
: undefined,
personas: raw.personas
? Object.fromEntries(
Object.entries(raw.personas).map(([name, override]) => [
name,
{ qualityGates: override.quality_gates },
])
)
: undefined,
};
}
export function denormalizePieceOverrides(
overrides: PieceOverrides | undefined,
): { quality_gates?: string[]; quality_gates_edit_only?: boolean; movements?: Record<string, { quality_gates?: string[] }> } | undefined {
): {
quality_gates?: string[];
quality_gates_edit_only?: boolean;
movements?: Record<string, { quality_gates?: string[] }>;
personas?: Record<string, { quality_gates?: string[] }>;
} | undefined {
if (!overrides) return undefined;
const result: { quality_gates?: string[]; quality_gates_edit_only?: boolean; movements?: Record<string, { quality_gates?: string[] }> } = {};
const result: {
quality_gates?: string[];
quality_gates_edit_only?: boolean;
movements?: Record<string, { quality_gates?: string[] }>;
personas?: Record<string, { quality_gates?: string[] }>;
} = {};
if (overrides.qualityGates !== undefined) {
result.quality_gates = overrides.qualityGates;
}
@ -80,6 +103,17 @@ export function denormalizePieceOverrides(
})
);
}
if (overrides.personas) {
result.personas = Object.fromEntries(
Object.entries(overrides.personas).map(([name, override]) => {
const personaOverride: { quality_gates?: string[] } = {};
if (override.qualityGates !== undefined) {
personaOverride.quality_gates = override.qualityGates;
}
return [name, personaOverride];
})
);
}
return Object.keys(result).length > 0 ? result : undefined;
}

View File

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

View File

@ -1,28 +1,11 @@
/**
* Global configuration public API.
* 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 {
invalidateGlobalConfigCache,
loadGlobalConfig,
loadGlobalMigratedProjectLocalFallback,
saveGlobalConfig,
validateCliPath,
} from './globalConfigCore.js';

View File

@ -1,7 +1,7 @@
import { readFileSync, existsSync, writeFileSync } from 'node:fs';
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
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 {
normalizeConfigProviderReference,
type ConfigProviderReference,
@ -9,16 +9,14 @@ import {
import {
normalizeProviderProfiles,
normalizePieceOverrides,
normalizePipelineConfig,
normalizePersonaProviders,
normalizeRuntime,
} from '../configNormalizers.js';
import { getGlobalConfigPath } from '../paths.js';
import { applyGlobalConfigEnvOverrides } from '../env/config-env-overrides.js';
import { invalidateAllResolvedConfigCache } from '../resolutionCache.js';
import { validateProviderModelCompatibility } from '../providerModelCompatibility.js';
import {
extractMigratedProjectLocalFallback,
removeMigratedProjectLocalKeys,
type GlobalMigratedProjectLocalFallback,
} from './globalMigratedProjectLocalFallback.js';
import { sanitizeConfigValue } from './globalConfigLegacyMigration.js';
import { serializeGlobalConfig } from './globalConfigSerializer.js';
export { validateCliPath } from './cliPathValidator.js';
@ -30,12 +28,11 @@ function getRecord(value: unknown): Record<string, unknown> | undefined {
return value as Record<string, unknown>;
}
type ProviderType = NonNullable<PersistedGlobalConfig['provider']>;
type ProviderType = NonNullable<GlobalConfig['provider']>;
type RawProviderReference = ConfigProviderReference<ProviderType>;
export class GlobalConfigManager {
private static instance: GlobalConfigManager | null = null;
private cachedConfig: PersistedGlobalConfig | null = null;
private cachedMigratedProjectLocalFallback: GlobalMigratedProjectLocalFallback | null = null;
private cachedConfig: GlobalConfig | null = null;
private constructor() {}
static getInstance(): GlobalConfigManager {
@ -51,10 +48,9 @@ export class GlobalConfigManager {
invalidateCache(): void {
this.cachedConfig = null;
this.cachedMigratedProjectLocalFallback = null;
}
load(): PersistedGlobalConfig {
load(): GlobalConfig {
if (this.cachedConfig !== null) {
return this.cachedConfig;
}
@ -78,17 +74,14 @@ export class GlobalConfigManager {
}
applyGlobalConfigEnvOverrides(rawConfig);
const migratedProjectLocalFallback = extractMigratedProjectLocalFallback(rawConfig);
const schemaInput = { ...rawConfig };
removeMigratedProjectLocalKeys(schemaInput);
const parsed = GlobalConfigSchema.parse(schemaInput);
const parsed = GlobalConfigSchema.parse(rawConfig);
const normalizedProvider = normalizeConfigProviderReference(
parsed.provider as RawProviderReference,
parsed.model,
parsed.provider_options as Record<string, unknown> | undefined,
);
const config: PersistedGlobalConfig = {
const config: GlobalConfig = {
language: parsed.language,
provider: normalizedProvider.provider,
model: normalizedProvider.model,
@ -126,9 +119,7 @@ export class GlobalConfigManager {
pieceCategoriesFile: parsed.piece_categories_file,
providerOptions: normalizedProvider.providerOptions,
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
? { prepare: [...new Set(parsed.runtime.prepare)] }
: undefined,
runtime: normalizeRuntime(parsed.runtime),
preventSleep: parsed.prevent_sleep,
notificationSound: parsed.notification_sound,
notificationSoundEvents: parsed.notification_sound_events ? {
@ -140,23 +131,33 @@ export class GlobalConfigManager {
} : undefined,
autoFetch: parsed.auto_fetch,
baseBranch: parsed.base_branch,
pieceOverrides: normalizePieceOverrides(parsed.piece_overrides as { quality_gates?: string[]; quality_gates_edit_only?: boolean; movements?: Record<string, { quality_gates?: string[] }> } | undefined),
pieceOverrides: normalizePieceOverrides(
parsed.piece_overrides as {
quality_gates?: string[];
quality_gates_edit_only?: boolean;
movements?: Record<string, { quality_gates?: string[] }>;
personas?: Record<string, { quality_gates?: string[] }>;
} | 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);
this.cachedConfig = config;
this.cachedMigratedProjectLocalFallback = migratedProjectLocalFallback;
return config;
}
loadMigratedProjectLocalFallback(): GlobalMigratedProjectLocalFallback {
if (this.cachedMigratedProjectLocalFallback !== null) {
return this.cachedMigratedProjectLocalFallback;
}
this.load();
return this.cachedMigratedProjectLocalFallback ?? {};
}
save(config: PersistedGlobalConfig): void {
save(config: GlobalConfig): void {
const configPath = getGlobalConfigPath();
const raw = serializeGlobalConfig(config);
writeFileSync(configPath, stringifyYaml(raw), 'utf-8');
@ -170,14 +171,10 @@ export function invalidateGlobalConfigCache(): void {
invalidateAllResolvedConfigCache();
}
export function loadGlobalConfig(): PersistedGlobalConfig {
export function loadGlobalConfig(): GlobalConfig {
return GlobalConfigManager.getInstance().load();
}
export function loadGlobalMigratedProjectLocalFallback(): GlobalMigratedProjectLocalFallback {
return GlobalConfigManager.getInstance().loadMigratedProjectLocalFallback();
}
export function saveGlobalConfig(config: PersistedGlobalConfig): void {
export function saveGlobalConfig(config: GlobalConfig): void {
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 { loadGlobalConfig, validateCliPath } from './globalConfigCore.js';
@ -24,7 +24,7 @@ export function resolveCodexCliPath(): string | undefined {
return validateCliPath(envPath, 'TAKT_CODEX_CLI_PATH');
}
const config: PersistedGlobalConfig = loadGlobalConfig();
const config: GlobalConfig = loadGlobalConfig();
if (config.codexCliPath === undefined) {
return undefined;
}
@ -37,7 +37,7 @@ export function resolveClaudeCliPath(): string | undefined {
return validateCliPath(envPath, 'TAKT_CLAUDE_CLI_PATH');
}
const config: PersistedGlobalConfig = loadGlobalConfig();
const config: GlobalConfig = loadGlobalConfig();
if (config.claudeCliPath === undefined) {
return undefined;
}
@ -50,7 +50,7 @@ export function resolveCursorCliPath(): string | undefined {
return validateCliPath(envPath, 'TAKT_CURSOR_CLI_PATH');
}
const config: PersistedGlobalConfig = loadGlobalConfig();
const config: GlobalConfig = loadGlobalConfig();
if (config.cursorCliPath === undefined) {
return undefined;
}
@ -79,7 +79,7 @@ export function resolveCopilotCliPath(): string | undefined {
return validateCliPath(envPath, 'TAKT_COPILOT_CLI_PATH');
}
const config: PersistedGlobalConfig = loadGlobalConfig();
const config: GlobalConfig = loadGlobalConfig();
if (config.copilotCliPath === 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 {
denormalizeProviderProfiles,
denormalizePieceOverrides,
denormalizeProviderOptions,
} from '../configNormalizers.js';
export function serializeGlobalConfig(config: PersistedGlobalConfig): Record<string, unknown> {
export function serializeGlobalConfig(config: GlobalConfig): Record<string, unknown> {
const raw: Record<string, unknown> = {
language: config.language,
provider: config.provider,
@ -147,5 +147,37 @@ export function serializeGlobalConfig(config: PersistedGlobalConfig): Record<str
if (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;
}

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

@ -20,13 +20,14 @@ import {
resolveRefList,
resolveSectionMap,
extractPersonaDisplayName,
isResourcePath,
resolvePersona,
} from './resource-resolver.js';
type RawStep = z.output<typeof PieceMovementRawSchema>;
import type { MovementProviderOptions } from '../../../core/models/piece-types.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 { loadProjectConfig } from '../project/projectConfig.js';
import { loadGlobalConfig } from '../global/globalConfig.js';
@ -244,10 +245,22 @@ function normalizeStepFromRaw(
const rules: PieceRule[] | undefined = step.rules?.map(normalizeRule);
const rawPersona = (step as Record<string, unknown>).persona as string | undefined;
if (rawPersona !== undefined && rawPersona.trim().length === 0) {
throw new Error(`Movement "${step.name}" has an empty persona value`);
}
const { personaSpec, personaPath } = resolvePersona(rawPersona, sections, pieceDir, context);
const displayName: string | undefined = (step as Record<string, unknown>).persona_name as string
|| undefined;
const displayNameRaw = (step as Record<string, unknown>).persona_name as string | undefined;
if (displayNameRaw !== undefined && displayNameRaw.trim().length === 0) {
throw new Error(`Movement "${step.name}" has an empty persona_name value`);
}
const displayName = displayNameRaw || undefined;
const derivedPersonaName = personaSpec ? extractPersonaDisplayName(personaSpec) : undefined;
const resolvedPersonaDisplayName = displayName || derivedPersonaName || step.name;
const normalizedRawPersona = rawPersona?.trim();
const personaOverrideKey = normalizedRawPersona
? (isResourcePath(normalizedRawPersona) ? extractPersonaDisplayName(normalizedRawPersona) : normalizedRawPersona)
: undefined;
const policyRef = (step as Record<string, unknown>).policy as string | string[] | undefined;
const policyContents = resolveRefList(policyRef, sections.resolvedPolicies, pieceDir, 'policies', context);
@ -265,7 +278,7 @@ function normalizeStepFromRaw(
description: step.description,
persona: personaSpec,
session: step.session,
personaDisplayName: displayName || (personaSpec ? extractPersonaDisplayName(personaSpec) : step.name),
personaDisplayName: resolvedPersonaDisplayName,
personaPath,
mcpServers: step.mcp_servers,
provider: normalizedProvider.provider ?? inheritedProvider,
@ -282,6 +295,7 @@ function normalizeStepFromRaw(
step.name,
step.quality_gates,
step.edit,
personaOverrideKey,
projectOverrides,
globalOverrides,
),

View File

@ -9,7 +9,7 @@
* 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.
@ -17,15 +17,18 @@ import type { PieceOverrides } from '../../../core/models/persisted-global-confi
* Merge order (gates are added in this sequence):
* 1. Global override in global config (filtered by edit flag if qualityGatesEditOnly=true)
* 2. Movement-specific override in global config
* 3. Global override in project config (filtered by edit flag if qualityGatesEditOnly=true)
* 4. Movement-specific override in project config
* 5. Piece YAML quality_gates
* 3. Persona-specific override in global config
* 4. Global override in project config (filtered by edit flag if qualityGatesEditOnly=true)
* 5. Movement-specific override in project config
* 6. Persona-specific override in project config
* 7. Piece YAML quality_gates
*
* Merge strategy: Additive merge (all gates are combined, no overriding)
*
* @param movementName - Name of the movement
* @param yamlGates - Quality gates from piece YAML
* @param editFlag - Whether the movement has edit: true
* @param personaName - Persona name used by the movement
* @param projectOverrides - Project-level piece_overrides (from .takt/config.yaml)
* @param globalOverrides - Global-level piece_overrides (from ~/.takt/config.yaml)
* @returns Merged quality gates array
@ -34,9 +37,15 @@ export function applyQualityGateOverrides(
movementName: string,
yamlGates: string[] | undefined,
editFlag: boolean | undefined,
personaName: string | undefined,
projectOverrides: PieceOverrides | undefined,
globalOverrides: PieceOverrides | undefined,
): string[] | undefined {
if (personaName !== undefined && personaName.trim().length === 0) {
throw new Error(`Invalid persona name for movement "${movementName}": empty value`);
}
const normalizedPersonaName = personaName?.trim();
// Track whether yamlGates was explicitly defined (even if empty)
const hasYamlGates = yamlGates !== undefined;
const gates: string[] = [];
@ -54,6 +63,14 @@ export function applyQualityGateOverrides(
gates.push(...globalMovementGates);
}
// Collect persona-specific gates from global config
const globalPersonaGates = normalizedPersonaName
? globalOverrides?.personas?.[normalizedPersonaName]?.qualityGates
: undefined;
if (globalPersonaGates) {
gates.push(...globalPersonaGates);
}
// Collect global gates from project config
const projectGlobalGates = projectOverrides?.qualityGates;
const projectEditOnly = projectOverrides?.qualityGatesEditOnly ?? false;
@ -67,6 +84,14 @@ export function applyQualityGateOverrides(
gates.push(...projectMovementGates);
}
// Collect persona-specific gates from project config
const projectPersonaGates = normalizedPersonaName
? projectOverrides?.personas?.[normalizedPersonaName]?.qualityGates
: undefined;
if (projectPersonaGates) {
gates.push(...projectPersonaGates);
}
// Add YAML gates (lowest priority)
if (yamlGates) {
gates.push(...yamlGates);

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 { ProjectConfigSchema } from '../../../core/models/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 {
normalizeConfigProviderReference,
@ -19,8 +19,6 @@ import {
normalizeRuntime,
} from '../configNormalizers.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 {
normalizeSubmodules,
@ -30,28 +28,16 @@ import {
formatIssuePath,
} from './projectConfigTransforms.js';
export type { ProjectLocalConfig } from '../types.js';
export type { ProjectConfig as ProjectLocalConfig } from '../types.js';
type Assert<T extends true> = T;
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 ProviderType = NonNullable<ProjectConfig['provider']>;
type RawProviderReference = ConfigProviderReference<ProviderType>;
/**
* Load project configuration from .takt/config.yaml
*/
export function loadProjectConfig(projectDir: string): ProjectLocalConfig {
export function loadProjectConfig(projectDir: string): ProjectConfig {
const configPath = getProjectConfigPath(projectDir);
const rawConfig: Record<string, unknown> = {};
if (existsSync(configPath)) {
const content = readFileSync(configPath, 'utf-8');
@ -92,10 +78,8 @@ export function loadProjectConfig(projectDir: string): ProjectLocalConfig {
provider_options,
provider_profiles,
analytics,
log_level,
pipeline,
persona_providers,
verbose,
branch_name_strategy,
minimal_output,
concurrency,
@ -103,35 +87,30 @@ export function loadProjectConfig(projectDir: string): ProjectLocalConfig {
interactive_preview_movements,
piece_overrides,
runtime,
...rest
} = parsedConfig;
const normalizedProvider = normalizeConfigProviderReference(
provider as RawProviderReference,
model as string | undefined,
provider_options as Record<string, unknown> | undefined,
);
const normalizedSubmodules = normalizeSubmodules(submodules);
const normalizedWithSubmodules = normalizeWithSubmodules(with_submodules);
const effectiveWithSubmodules = normalizedSubmodules === undefined ? normalizedWithSubmodules : undefined;
const normalizedPipeline = normalizePipelineConfig(
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,
);
return {
...(rest as ProjectLocalConfig),
logLevel: log_level as ProjectLocalConfig['logLevel'],
pipeline: normalizedPipeline,
personaProviders,
branchNameStrategy: branch_name_strategy as ProjectLocalConfig['branchNameStrategy'],
personaProviders: normalizedPersonaProviders,
branchNameStrategy: branch_name_strategy as ProjectConfig['branchNameStrategy'],
minimalOutput: minimal_output as boolean | undefined,
concurrency: concurrency as number | undefined,
taskPollIntervalMs: task_poll_interval_ms as number | undefined,
interactivePreviewMovements: interactive_preview_movements as number | undefined,
verbose: verbose as boolean | undefined,
autoPr: auto_pr as boolean | undefined,
draftPr: draft_pr as boolean | undefined,
baseBranch: base_branch as string | undefined,
@ -142,7 +121,14 @@ export function loadProjectConfig(projectDir: string): ProjectLocalConfig {
model: normalizedProvider.model,
providerOptions: normalizedProvider.providerOptions,
providerProfiles: normalizeProviderProfiles(provider_profiles as Record<string, { default_permission_mode: unknown; movement_permission_overrides?: Record<string, unknown> }> | undefined),
pieceOverrides: normalizePieceOverrides(piece_overrides as { quality_gates?: string[]; quality_gates_edit_only?: boolean; movements?: Record<string, { quality_gates?: string[] }> } | undefined),
pieceOverrides: normalizePieceOverrides(
piece_overrides as {
quality_gates?: string[];
quality_gates_edit_only?: boolean;
movements?: Record<string, { quality_gates?: string[] }>;
personas?: Record<string, { quality_gates?: string[] }>;
} | undefined
),
runtime: normalizeRuntime(runtime),
};
}
@ -150,14 +136,12 @@ export function loadProjectConfig(projectDir: string): ProjectLocalConfig {
/**
* 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 configPath = getProjectConfigPath(projectDir);
if (!existsSync(configDir)) {
mkdirSync(configDir, { recursive: true });
}
copyProjectResourcesToDir(configDir);
const savePayload: Record<string, unknown> = { ...config };
@ -184,49 +168,15 @@ export function saveProjectConfig(projectDir: string, config: ProjectLocalConfig
}
delete savePayload.providerProfiles;
delete savePayload.providerOptions;
delete savePayload.concurrency;
delete savePayload.verbose;
if (config.autoPr !== undefined) savePayload.auto_pr = config.autoPr;
if (config.draftPr !== undefined) savePayload.draft_pr = config.draftPr;
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.minimalOutput !== undefined
&& config.minimalOutput !== MIGRATED_PROJECT_LOCAL_DEFAULTS.minimalOutput
) {
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;
}
if (config.minimalOutput !== undefined) savePayload.minimal_output = config.minimalOutput;
if (config.taskPollIntervalMs !== undefined) savePayload.task_poll_interval_ms = config.taskPollIntervalMs;
if (config.interactivePreviewMovements !== undefined) savePayload.interactive_preview_movements = config.interactivePreviewMovements;
if (config.concurrency !== undefined) savePayload.concurrency = config.concurrency;
delete savePayload.pipeline;
if (config.pipeline) {
const pipelineRaw: Record<string, unknown> = {};
@ -243,6 +193,8 @@ export function saveProjectConfig(projectDir: string, config: ProjectLocalConfig
}
if (config.personaProviders && Object.keys(config.personaProviders).length > 0) {
savePayload.persona_providers = config.personaProviders;
} else {
delete savePayload.persona_providers;
}
if (normalizedSubmodules !== undefined) {
savePayload.submodules = normalizedSubmodules;
@ -259,7 +211,6 @@ export function saveProjectConfig(projectDir: string, config: ProjectLocalConfig
delete savePayload.draftPr;
delete savePayload.baseBranch;
delete savePayload.withSubmodules;
delete savePayload.logLevel;
delete savePayload.branchNameStrategy;
delete savePayload.minimalOutput;
delete savePayload.taskPollIntervalMs;
@ -284,10 +235,10 @@ export function saveProjectConfig(projectDir: string, config: ProjectLocalConfig
invalidateResolvedConfigCache(projectDir);
}
export function updateProjectConfig<K extends keyof ProjectLocalConfig>(
export function updateProjectConfig<K extends keyof ProjectConfig>(
projectDir: string,
key: K,
value: ProjectLocalConfig[K]
value: ProjectConfig[K]
): void {
const config = loadProjectConfig(projectDir);
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';

View File

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

View File

@ -9,11 +9,6 @@ import {
setCachedResolvedValue,
} from './resolutionCache.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 { invalidateResolvedConfigCache, invalidateAllResolvedConfigCache } from './resolutionCache.js';
@ -41,9 +36,14 @@ interface ResolutionRule<K extends ConfigParameterKey> {
mergeMode?: 'analytics';
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) {
const cached = getCachedProjectConfig(projectDir);
@ -67,15 +67,7 @@ const PROVIDER_OPTIONS_ENV_PATHS = [
'provider_options.claude.sandbox.excluded_commands',
] 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> }> = {
logLevel: { layers: ['local', 'global'] },
provider: {
layers: ['local', 'piece', 'global'],
pieceValue: (pieceContext) => pieceContext?.provider,
@ -91,7 +83,6 @@ const RESOLUTION_REGISTRY: Partial<{ [K in ConfigParameterKey]: ResolutionRule<K
autoPr: { layers: ['local', 'global'] },
draftPr: { layers: ['local', 'global'] },
analytics: { layers: ['local', 'global'], mergeMode: 'analytics' },
...MIGRATED_PROJECT_LOCAL_RESOLUTION_REGISTRY,
autoFetch: { layers: ['global'] },
baseBranch: { layers: ['local', 'global'] },
pieceOverrides: { layers: ['local', 'global'] },
@ -132,16 +123,8 @@ function getLocalLayerValue<K extends ConfigParameterKey>(
function getGlobalLayerValue<K extends ConfigParameterKey>(
global: ReturnType<typeof globalConfigModule.loadGlobalConfig>,
globalMigratedProjectLocalFallback: GlobalMigratedProjectLocalFallback,
key: K,
): 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;
}
@ -149,7 +132,6 @@ function resolveByRegistry<K extends ConfigParameterKey>(
key: K,
project: ReturnType<typeof loadProjectConfigCached>,
global: ReturnType<typeof globalConfigModule.loadGlobalConfig>,
globalMigratedProjectLocalFallback: GlobalMigratedProjectLocalFallback,
options: ResolveConfigOptions | undefined,
): ResolvedConfigValue<K> {
const rule = (RESOLUTION_REGISTRY[key] ?? DEFAULT_RULE) as ResolutionRule<K>;
@ -167,7 +149,7 @@ function resolveByRegistry<K extends ConfigParameterKey>(
} else if (layer === 'piece') {
value = rule.pieceValue?.(options?.pieceContext);
} else {
value = getGlobalLayerValue(global, globalMigratedProjectLocalFallback, key);
value = getGlobalLayerValue(global, key);
}
if (value !== undefined) {
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) {
return { value: fallbackDefaultValue as LoadedConfig[K], source: 'default' };
}
@ -202,16 +184,7 @@ function resolveUncachedConfigValue<K extends ConfigParameterKey>(
): ResolvedConfigValue<K> {
const project = loadProjectConfigCached(projectDir);
const global = globalConfigModule.loadGlobalConfig();
const globalMigratedProjectLocalFallback = isMigratedProjectLocalConfigKey(key)
? 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);
return resolveByRegistry(key, project, global, options);
}
export function resolveConfigValueWithSource<K extends ConfigParameterKey>(
@ -249,19 +222,10 @@ export function resolveConfigValues<K extends ConfigParameterKey>(
return result;
}
export function isVerboseShortcutEnabled(
export function isDebugLoggingEnabled(
projectDir: string,
options?: ResolveConfigOptions,
): boolean {
const verbose = resolveConfigValue(projectDir, 'verbose', options);
if (verbose === true) {
return true;
}
const logging = resolveConfigValue(projectDir, 'logging', options);
if (logging?.debug === true || logging?.trace === true) {
return true;
}
return resolveConfigValue(projectDir, 'logLevel', options) === 'debug';
return logging?.debug === true || logging?.trace === true || logging?.level === 'debug';
}

View File

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

View File

@ -1,62 +1,11 @@
/**
* 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';
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;
}
export type { ProjectConfig, ProjectConfig as ProjectLocalConfig } from '../../core/models/config-types.js';
/** Persona session data for persistence */
export interface PersonaSessionData {

View File

@ -8,6 +8,7 @@
import { readFileSync, existsSync } from 'node:fs';
import type { ScenarioEntry } from './types.js';
import { STATUS_VALUES } from '../../core/models/status.js';
export type { ScenarioEntry };
@ -130,11 +131,10 @@ function validateEntry(entry: unknown, index: number): ScenarioEntry {
}
// status defaults to 'done'
const validStatuses = ['done', 'blocked', 'error', 'approved', 'rejected', 'improve'] as const;
const status = obj.status ?? 'done';
if (typeof status !== 'string' || !validStatuses.includes(status as typeof validStatuses[number])) {
if (typeof status !== 'string' || !STATUS_VALUES.includes(status as typeof STATUS_VALUES[number])) {
throw new Error(
`Scenario entry [${index}] has invalid status "${String(status)}". Valid: ${validStatuses.join(', ')}`,
`Scenario entry [${index}] has invalid status "${String(status)}". Valid: ${STATUS_VALUES.join(', ')}`,
);
}

View File

@ -3,6 +3,7 @@
*/
import type { StreamCallback } from '../claude/index.js';
import type { Status } from '../../core/models/status.js';
/** Options for mock calls */
export interface MockCallOptions {
@ -12,7 +13,7 @@ export interface MockCallOptions {
/** Fixed response content (optional, defaults to generic mock response) */
mockResponse?: string;
/** Fixed status to return (optional, defaults to 'done') */
mockStatus?: 'done' | 'blocked' | 'error' | 'approved' | 'rejected' | 'improve';
mockStatus?: Status;
/** Structured output payload returned as-is */
structuredOutput?: Record<string, unknown>;
}
@ -22,7 +23,7 @@ export interface ScenarioEntry {
/** Persona name to match (optional — if omitted, consumed by call order) */
persona?: string;
/** Response status */
status: 'done' | 'blocked' | 'error' | 'approved' | 'rejected' | 'improve';
status: Status;
/** Response content body */
content: string;
/** Optional structured output payload (for outputSchema-driven flows) */