Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
16596eff09 | ||
|
|
bc5e1fd860 | ||
|
|
a8223d231d | ||
|
|
ebbd1a67a9 | ||
|
|
a69e9f4fb3 | ||
|
|
7bfc7954aa | ||
|
|
903111dd74 |
@ -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
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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:
|
||||
|
||||
66
builtins/en/facets/knowledge/task-decomposition.md
Normal file
66
builtins/en/facets/knowledge/task-decomposition.md
Normal 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
|
||||
```
|
||||
@ -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
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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 # ブックマーク保存先
|
||||
|
||||
@ -1,9 +1,11 @@
|
||||
テスト実行、ビルド確認、最終承認を行ってください。
|
||||
|
||||
**ピース全体の確認:**
|
||||
1. 計画と実装結果が一致しているか
|
||||
2. 各レビュームーブメントの指摘が対応されているか
|
||||
3. タスク指示書の各要件が達成されているか
|
||||
1. レポートディレクトリ内の全レポートを確認し、ピース全体の整合性をチェックする
|
||||
- 計画と実装結果が一致しているか
|
||||
- 各レビュームーブメントの指摘が適切に対応されているか
|
||||
- タスクの本来の目的が達成されているか
|
||||
2. タスク指示書の各要件が達成されているか
|
||||
- タスク指示書から要件を1つずつ抽出する
|
||||
- 各要件について、実装されたコード(ファイル:行)を特定する
|
||||
- コードが要件を満たしていることを実際に確認する(ファイルを読む、テストを実行する)
|
||||
|
||||
@ -1,17 +1,19 @@
|
||||
実装タスクをファイル担当単位でサブタスクに分解し、並列実行してください。各パートが担当するファイルが重複しないよう排他的に割り当てます。
|
||||
実装タスクを分析し、分解が適切なら複数パートに分けて並列実行してください。
|
||||
|
||||
**重要:** 計画レポートを参照してください: {report:plan.md}
|
||||
|
||||
**やること:**
|
||||
|
||||
1. 変更対象ファイルを特定する
|
||||
- 計画レポートとテストスコープを参照して変更・作成するファイルを洗い出す
|
||||
- 実際のコードベースを確認して不明点を補完する
|
||||
1. 分解の可否を判断する
|
||||
- 変更対象ファイルを特定し、ファイル間の依存関係を確認する
|
||||
- 横断的関心事(共有型・ID・イベント)がある場合は分解せず1パートで実装する
|
||||
- 変更ファイル数が少ない場合、リファクタ・リネーム系の場合も1パートで実装する
|
||||
|
||||
2. ファイルをレイヤー/モジュール単位でグループ化する
|
||||
2. 分解する場合: ファイルをレイヤー/モジュール単位でグループ化する
|
||||
- 凝集度の高い単位でグループを作る(例: ドメイン層 / インフラ層 / API層)
|
||||
- 型・インターフェースの依存がある場合は、依存元と依存先を同じグループにまとめる
|
||||
- 1つのファイルを複数のパートに割り当てない
|
||||
- テストファイルと実装ファイルは同じパートにまとめる
|
||||
|
||||
3. 各パートに排他的なファイル担当を割り当てる
|
||||
- 各パートの instruction に以下を必ず明記する:
|
||||
|
||||
66
builtins/ja/facets/knowledge/task-decomposition.md
Normal file
66
builtins/ja/facets/knowledge/task-decomposition.md
Normal 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 の生成から消費まで一貫して実装
|
||||
```
|
||||
@ -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修正」タスクでバックエンドのドメインモデルが構造変更されている
|
||||
- 「表示変更」タスクでビジネスロジックのフローが書き換えられている
|
||||
|
||||
### ピース全体の見直し
|
||||
|
||||
レポートディレクトリ内の全レポートを確認し、ピース全体の整合性をチェックする。
|
||||
|
||||
- 計画と実装結果が一致しているか
|
||||
- 各レビュームーブメントの指摘が適切に対応されているか
|
||||
- タスクの本来の目的が達成されているか
|
||||
|
||||
@ -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:
|
||||
|
||||
71
src/__tests__/claude-client-status.test.ts
Normal file
71
src/__tests__/claude-client-status.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@ -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 () => {
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
@ -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', () => {
|
||||
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
// =====================================================
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
// =====================================================
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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',
|
||||
});
|
||||
});
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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');
|
||||
});
|
||||
|
||||
@ -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),
|
||||
}));
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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']);
|
||||
});
|
||||
|
||||
@ -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/);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
@ -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', () => {
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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();
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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 =
|
||||
|
||||
@ -68,4 +68,4 @@ export type {
|
||||
Language,
|
||||
PipelineConfig,
|
||||
ProjectConfig,
|
||||
} from './persisted-global-config.js';
|
||||
} from './config-types.js';
|
||||
|
||||
@ -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)', {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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';
|
||||
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
2
src/infra/config/env/config-env-overrides.ts
vendored
2
src/infra/config/env/config-env-overrides.ts
vendored
@ -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' },
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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'],
|
||||
};
|
||||
}
|
||||
@ -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,
|
||||
),
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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>>;
|
||||
@ -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[],
|
||||
);
|
||||
@ -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;
|
||||
|
||||
@ -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';
|
||||
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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';
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -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(', ')}`,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -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) */
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user