feat: resolve movement permissions via provider profiles with required floor

This commit is contained in:
nrslib 2026-02-15 07:00:03 +09:00
parent e1a5d7a386
commit f065ee510f
61 changed files with 726 additions and 213 deletions

View File

@ -1,91 +1,92 @@
# TAKT Global Configuration
# TAKT global configuration sample
# Location: ~/.takt/config.yaml
# ── Basic ──
# Language (en | ja)
# ---- Core ----
language: en
# Default piece when no piece is specified
default_piece: default
# Log level (debug | info | warn | error)
log_level: info
# ── Provider & Model ──
# Provider runtime (claude | codex)
# ---- Provider ----
# provider: claude | codex | opencode | mock
provider: claude
# Default model (optional)
# Claude: opus, sonnet, haiku
# Codex: gpt-5.2-codex, gpt-5.1-codex, etc.
# Model (optional)
# Claude examples: opus, sonnet, haiku
# Codex examples: gpt-5.2-codex, gpt-5.1-codex
# OpenCode format: provider/model
# model: sonnet
# Per-persona provider override (optional)
# Override provider for specific personas. Others use the global provider.
# Per-persona provider override
# persona_providers:
# coder: codex
# reviewer: claude
# ── API Keys ──
# Optional. Environment variables take priority:
# TAKT_ANTHROPIC_API_KEY, TAKT_OPENAI_API_KEY
# Provider-specific movement permission policy
# Priority:
# 1) project provider_profiles override
# 2) global provider_profiles override
# 3) project provider_profiles default
# 4) global provider_profiles default
# 5) movement.required_permission_mode (minimum floor)
# provider_profiles:
# codex:
# default_permission_mode: full
# movement_permission_overrides:
# ai_review: readonly
# claude:
# default_permission_mode: edit
# Provider-specific runtime options
# provider_options:
# codex:
# network_access: true
# claude:
# sandbox:
# allow_unsandboxed_commands: true
# ---- API Keys ----
# Environment variables take priority:
# TAKT_ANTHROPIC_API_KEY / TAKT_OPENAI_API_KEY / TAKT_OPENCODE_API_KEY
# anthropic_api_key: ""
# openai_api_key: ""
# opencode_api_key: ""
# ── Execution ──
# Worktree (shared clone) directory (default: ../{clone-name} relative to project)
# worktree_dir: ~/takt-worktrees
# Auto-create PR after worktree execution (default: prompt in interactive mode)
# auto_pr: false
# Prevent macOS idle sleep during execution using caffeinate (default: false)
# prevent_sleep: false
# Runtime environment defaults (applies to all pieces unless piece_config.runtime overrides)
# ---- Runtime ----
# Global runtime preparation (piece_config.runtime overrides this)
# runtime:
# prepare:
# - gradle
# - node
# ── Parallel Execution (takt run) ──
# ---- Execution ----
# worktree_dir: ~/takt-worktrees
# auto_pr: false
# prevent_sleep: false
# Number of tasks to run concurrently (1 = sequential, max: 10)
# ---- Run Loop ----
# concurrency: 1
# Polling interval in ms for picking up new tasks (100-5000, default: 500)
# task_poll_interval_ms: 500
# ── Interactive Mode ──
# Number of movement previews shown in interactive mode (0 to disable, max: 10)
# interactive_preview_movements: 3
# Branch name generation strategy (romaji: fast default | ai: slow)
# branch_name_strategy: romaji
# ── Output ──
# Notification sounds (default: true)
# notification_sound: true
# Minimal output for CI - suppress AI output (default: false)
# ---- Output ----
# minimal_output: false
# notification_sound: true
# notification_sound_events:
# iteration_limit: true
# piece_complete: true
# piece_abort: true
# run_complete: true
# run_abort: true
# observability:
# provider_events: true
# ── Builtin Pieces ──
# Enable builtin pieces (default: true)
# ---- Builtins ----
# enable_builtin_pieces: true
# Exclude specific builtins from loading
# disabled_builtins:
# - magi
# ── Pipeline Mode (--pipeline) ──
# ---- Pipeline ----
# pipeline:
# default_branch_prefix: "takt/"
# commit_message_template: "feat: {title} (#{issue})"
@ -94,14 +95,11 @@ provider: claude
# {issue_body}
# Closes #{issue}
# ── Preferences ──
# Custom paths for preference files
# ---- Preferences ----
# bookmarks_file: ~/.takt/preferences/bookmarks.yaml
# piece_categories_file: ~/.takt/preferences/piece-categories.yaml
# ── Debug ──
# ---- Debug ----
# debug:
# enabled: false
# log_file: ~/.takt/logs/debug.log

View File

@ -219,7 +219,7 @@ movements:
- Bash
- WebSearch
- WebFetch
permission_mode: edit
required_permission_mode: edit
rules:
- condition: Fix complete
next: reviewers

View File

@ -216,7 +216,7 @@ movements:
- Bash
- WebSearch
- WebFetch
permission_mode: edit
required_permission_mode: edit
rules:
- condition: Fix complete
next: reviewers

View File

@ -51,7 +51,7 @@ movements:
- Bash
- WebSearch
- WebFetch
permission_mode: edit
required_permission_mode: edit
rules:
- condition: Implementation complete
next: reviewers
@ -132,7 +132,7 @@ movements:
- Bash
- WebSearch
- WebFetch
permission_mode: edit
required_permission_mode: edit
rules:
- condition: Fix complete
next: reviewers

View File

@ -82,7 +82,7 @@ movements:
- Bash
- WebSearch
- WebFetch
permission_mode: edit
required_permission_mode: edit
rules:
- condition: Implementation complete
next: ai_review
@ -142,7 +142,7 @@ movements:
- Bash
- WebSearch
- WebFetch
permission_mode: edit
required_permission_mode: edit
rules:
- condition: AI issues fixed
next: ai_review
@ -234,7 +234,7 @@ movements:
- Bash
- WebSearch
- WebFetch
permission_mode: edit
required_permission_mode: edit
rules:
- condition: Fix complete
next: reviewers

View File

@ -85,7 +85,7 @@ movements:
- Bash
- WebSearch
- WebFetch
permission_mode: edit
required_permission_mode: edit
rules:
- condition: Test implementation complete
next: ai_review
@ -145,7 +145,7 @@ movements:
- Bash
- WebSearch
- WebFetch
permission_mode: edit
required_permission_mode: edit
rules:
- condition: AI issues fixed
next: ai_review
@ -212,7 +212,7 @@ movements:
- Bash
- WebSearch
- WebFetch
permission_mode: edit
required_permission_mode: edit
rules:
- condition: Fix complete
next: review_test

View File

@ -254,7 +254,7 @@ movements:
- Bash
- WebSearch
- WebFetch
permission_mode: edit
required_permission_mode: edit
rules:
- condition: Fix complete
next: reviewers

View File

@ -251,7 +251,7 @@ movements:
- Bash
- WebSearch
- WebFetch
permission_mode: edit
required_permission_mode: edit
rules:
- condition: Fix complete
next: reviewers

View File

@ -235,7 +235,7 @@ movements:
- Bash
- WebSearch
- WebFetch
permission_mode: edit
required_permission_mode: edit
rules:
- condition: Fix complete
next: reviewers

View File

@ -25,7 +25,7 @@ movements:
- Bash
- WebSearch
- WebFetch
permission_mode: edit
required_permission_mode: edit
instruction: implement
rules:
- condition: Implementation complete
@ -106,7 +106,7 @@ movements:
- Bash
- WebSearch
- WebFetch
permission_mode: edit
required_permission_mode: edit
rules:
- condition: AI Reviewer's issues fixed
- condition: No fix needed (verified target files/spec)
@ -126,7 +126,7 @@ movements:
- Bash
- WebSearch
- WebFetch
permission_mode: edit
required_permission_mode: edit
rules:
- condition: Supervisor's issues fixed
- condition: Cannot proceed, insufficient info
@ -151,7 +151,7 @@ movements:
- Bash
- WebSearch
- WebFetch
permission_mode: edit
required_permission_mode: edit
rules:
- condition: AI Reviewer's issues fixed
next: reviewers
@ -175,7 +175,7 @@ movements:
- Bash
- WebSearch
- WebFetch
permission_mode: edit
required_permission_mode: edit
rules:
- condition: Supervisor's issues fixed
next: reviewers

View File

@ -25,7 +25,7 @@ movements:
- Bash
- WebSearch
- WebFetch
permission_mode: edit
required_permission_mode: edit
rules:
- condition: Task complete
next: COMPLETE

View File

@ -25,7 +25,7 @@ movements:
- Bash
- WebSearch
- WebFetch
permission_mode: edit
required_permission_mode: edit
instruction: implement
rules:
- condition: Implementation complete
@ -106,7 +106,7 @@ movements:
- Bash
- WebSearch
- WebFetch
permission_mode: edit
required_permission_mode: edit
rules:
- condition: AI Reviewer's issues fixed
- condition: No fix needed (verified target files/spec)
@ -126,7 +126,7 @@ movements:
- Bash
- WebSearch
- WebFetch
permission_mode: edit
required_permission_mode: edit
rules:
- condition: Supervisor's issues fixed
- condition: Cannot proceed, insufficient info
@ -151,7 +151,7 @@ movements:
- Bash
- WebSearch
- WebFetch
permission_mode: edit
required_permission_mode: edit
rules:
- condition: AI Reviewer's issues fixed
next: reviewers
@ -175,7 +175,7 @@ movements:
- Bash
- WebSearch
- WebFetch
permission_mode: edit
required_permission_mode: edit
rules:
- condition: Supervisor's issues fixed
next: reviewers

View File

@ -226,7 +226,7 @@ movements:
- Bash
- WebSearch
- WebFetch
permission_mode: edit
required_permission_mode: edit
instruction: implement
rules:
- condition: Implementation complete
@ -309,7 +309,7 @@ movements:
- Bash
- WebSearch
- WebFetch
permission_mode: edit
required_permission_mode: edit
rules:
- condition: Fix complete
next: reviewers

View File

@ -85,7 +85,7 @@ movements:
- Bash
- WebSearch
- WebFetch
permission_mode: edit
required_permission_mode: edit
rules:
- condition: Test implementation complete
next: ai_review
@ -145,7 +145,7 @@ movements:
- Bash
- WebSearch
- WebFetch
permission_mode: edit
required_permission_mode: edit
rules:
- condition: AI issues fixed
next: ai_review
@ -212,7 +212,7 @@ movements:
- Bash
- WebSearch
- WebFetch
permission_mode: edit
required_permission_mode: edit
rules:
- condition: Fix complete
next: review_test

View File

@ -1,91 +1,92 @@
# TAKT グローバル設定
# TAKT グローバル設定サンプル
# 配置場所: ~/.takt/config.yaml
# ── 基本設定 ──
# 言語 (en | ja)
# ---- 基本 ----
language: ja
# デフォルトピース(指定なし時に使用)
default_piece: default
# ログレベル (debug | info | warn | error)
log_level: info
# ── プロバイダー & モデル ──
# プロバイダー (claude | codex)
# ---- プロバイダー ----
# provider: claude | codex | opencode | mock
provider: claude
# デフォルトモデル(オプション)
# Claude: opus, sonnet, haiku
# Codex: gpt-5.2-codex, gpt-5.1-codex など
# モデル(任意)
# Claude 例: opus, sonnet, haiku
# Codex 例: gpt-5.2-codex, gpt-5.1-codex
# OpenCode 形式: provider/model
# model: sonnet
# ペルソナ単位のプロバイダー上書き(オプション)
# 特定ペルソナだけプロバイダーを変更。未指定のペルソナはグローバル設定を使用。
# ペルソナ別プロバイダー上書き
# persona_providers:
# coder: codex
# reviewer: claude
# ── APIキー ──
# オプション。環境変数が優先:
# TAKT_ANTHROPIC_API_KEY, TAKT_OPENAI_API_KEY
# プロバイダー別 movement 権限ポリシー
# 優先順:
# 1) project provider_profiles override
# 2) global provider_profiles override
# 3) project provider_profiles default
# 4) global provider_profiles default
# 5) movement.required_permission_mode下限補正
# provider_profiles:
# codex:
# default_permission_mode: full
# movement_permission_overrides:
# ai_review: readonly
# claude:
# default_permission_mode: edit
# プロバイダー別ランタイムオプション
# provider_options:
# codex:
# network_access: true
# claude:
# sandbox:
# allow_unsandboxed_commands: true
# ---- API キー ----
# 環境変数が優先:
# TAKT_ANTHROPIC_API_KEY / TAKT_OPENAI_API_KEY / TAKT_OPENCODE_API_KEY
# anthropic_api_key: ""
# openai_api_key: ""
# opencode_api_key: ""
# ── 実行設定 ──
# ワークツリーshared cloneディレクトリデフォルト: プロジェクトの ../{clone-name}
# worktree_dir: ~/takt-worktrees
# ワークツリー実行後に自動PR作成デフォルト: 対話モードで確認)
# auto_pr: false
# macOS のアイドルスリープを防止(デフォルト: false
# prevent_sleep: false
# 実行時ランタイム環境のデフォルトpiece_config.runtime があればそちらを優先)
# ---- ランタイム ----
# グローバルなランタイム準備piece_config.runtime があればそちらを優先)
# runtime:
# prepare:
# - gradle
# - node
# ── 並列実行 (takt run) ──
# ---- 実行 ----
# worktree_dir: ~/takt-worktrees
# auto_pr: false
# prevent_sleep: false
# タスクの同時実行数1 = 逐次実行、最大: 10
# ---- Run Loop ----
# concurrency: 1
# 新規タスクのポーリング間隔 ms100-5000、デフォルト: 500
# task_poll_interval_ms: 500
# ── 対話モード ──
# ムーブメントプレビューの表示数0 で無効、最大: 10
# interactive_preview_movements: 3
# ブランチ名の生成方式romaji: 高速デフォルト | ai: 低速)
# branch_name_strategy: romaji
# ── 出力 ──
# 通知音(デフォルト: true
# notification_sound: true
# CI 向け最小出力 - AI 出力を抑制(デフォルト: false
# ---- 出力 ----
# minimal_output: false
# notification_sound: true
# notification_sound_events:
# iteration_limit: true
# piece_complete: true
# piece_abort: true
# run_complete: true
# run_abort: true
# observability:
# provider_events: true
# ── ビルトインピース ──
# ビルトインピースの有効化(デフォルト: true
# ---- Builtins ----
# enable_builtin_pieces: true
# 特定のビルトインを除外
# disabled_builtins:
# - magi
# ── パイプラインモード (--pipeline) ──
# ---- Pipeline ----
# pipeline:
# default_branch_prefix: "takt/"
# commit_message_template: "feat: {title} (#{issue})"
@ -94,14 +95,11 @@ provider: claude
# {issue_body}
# Closes #{issue}
# ── プリファレンス ──
# プリファレンスファイルのカスタムパス
# ---- Preferences ----
# bookmarks_file: ~/.takt/preferences/bookmarks.yaml
# piece_categories_file: ~/.takt/preferences/piece-categories.yaml
# ── デバッグ ──
# ---- Debug ----
# debug:
# enabled: false
# log_file: ~/.takt/logs/debug.log

View File

@ -219,7 +219,7 @@ movements:
- Bash
- WebSearch
- WebFetch
permission_mode: edit
required_permission_mode: edit
rules:
- condition: 修正が完了した
next: reviewers

View File

@ -216,7 +216,7 @@ movements:
- Bash
- WebSearch
- WebFetch
permission_mode: edit
required_permission_mode: edit
rules:
- condition: 修正が完了した
next: reviewers

View File

@ -51,7 +51,7 @@ movements:
- Bash
- WebSearch
- WebFetch
permission_mode: edit
required_permission_mode: edit
rules:
- condition: 実装完了
next: reviewers
@ -132,7 +132,7 @@ movements:
- Bash
- WebSearch
- WebFetch
permission_mode: edit
required_permission_mode: edit
rules:
- condition: 修正完了
next: reviewers

View File

@ -82,7 +82,7 @@ movements:
- Bash
- WebSearch
- WebFetch
permission_mode: edit
required_permission_mode: edit
rules:
- condition: 実装完了
next: ai_review
@ -142,7 +142,7 @@ movements:
- Bash
- WebSearch
- WebFetch
permission_mode: edit
required_permission_mode: edit
rules:
- condition: AI問題の修正完了
next: ai_review
@ -234,7 +234,7 @@ movements:
- Bash
- WebSearch
- WebFetch
permission_mode: edit
required_permission_mode: edit
rules:
- condition: 修正完了
next: reviewers

View File

@ -85,7 +85,7 @@ movements:
- Bash
- WebSearch
- WebFetch
permission_mode: edit
required_permission_mode: edit
rules:
- condition: テスト実装完了
next: ai_review
@ -145,7 +145,7 @@ movements:
- Bash
- WebSearch
- WebFetch
permission_mode: edit
required_permission_mode: edit
rules:
- condition: AI問題の修正完了
next: ai_review
@ -212,7 +212,7 @@ movements:
- Bash
- WebSearch
- WebFetch
permission_mode: edit
required_permission_mode: edit
rules:
- condition: 修正完了
next: review_test

View File

@ -254,7 +254,7 @@ movements:
- Bash
- WebSearch
- WebFetch
permission_mode: edit
required_permission_mode: edit
rules:
- condition: 修正が完了した
next: reviewers

View File

@ -251,7 +251,7 @@ movements:
- Bash
- WebSearch
- WebFetch
permission_mode: edit
required_permission_mode: edit
rules:
- condition: 修正が完了した
next: reviewers

View File

@ -235,7 +235,7 @@ movements:
- Bash
- WebSearch
- WebFetch
permission_mode: edit
required_permission_mode: edit
rules:
- condition: 修正が完了した
next: reviewers

View File

@ -25,7 +25,7 @@ movements:
- Bash
- WebSearch
- WebFetch
permission_mode: edit
required_permission_mode: edit
instruction: implement
rules:
- condition: 実装が完了した
@ -106,7 +106,7 @@ movements:
- Bash
- WebSearch
- WebFetch
permission_mode: edit
required_permission_mode: edit
rules:
- condition: AI問題の修正完了
- condition: 修正不要(指摘対象ファイル/仕様の確認済み)
@ -126,7 +126,7 @@ movements:
- Bash
- WebSearch
- WebFetch
permission_mode: edit
required_permission_mode: edit
rules:
- condition: 監督者の指摘に対する修正が完了した
- condition: 修正を進行できない
@ -151,7 +151,7 @@ movements:
- Bash
- WebSearch
- WebFetch
permission_mode: edit
required_permission_mode: edit
rules:
- condition: AI問題の修正完了
next: reviewers
@ -175,7 +175,7 @@ movements:
- Bash
- WebSearch
- WebFetch
permission_mode: edit
required_permission_mode: edit
rules:
- condition: 監督者の指摘に対する修正が完了した
next: reviewers

View File

@ -25,7 +25,7 @@ movements:
- Bash
- WebSearch
- WebFetch
permission_mode: edit
required_permission_mode: edit
rules:
- condition: タスク完了
next: COMPLETE

View File

@ -25,7 +25,7 @@ movements:
- Bash
- WebSearch
- WebFetch
permission_mode: edit
required_permission_mode: edit
instruction: implement
rules:
- condition: 実装が完了した
@ -106,7 +106,7 @@ movements:
- Bash
- WebSearch
- WebFetch
permission_mode: edit
required_permission_mode: edit
rules:
- condition: AI問題の修正完了
- condition: 修正不要(指摘対象ファイル/仕様の確認済み)
@ -126,7 +126,7 @@ movements:
- Bash
- WebSearch
- WebFetch
permission_mode: edit
required_permission_mode: edit
rules:
- condition: 監督者の指摘に対する修正が完了した
- condition: 修正を進行できない
@ -151,7 +151,7 @@ movements:
- Bash
- WebSearch
- WebFetch
permission_mode: edit
required_permission_mode: edit
rules:
- condition: AI問題の修正完了
next: reviewers
@ -175,7 +175,7 @@ movements:
- Bash
- WebSearch
- WebFetch
permission_mode: edit
required_permission_mode: edit
rules:
- condition: 監督者の指摘に対する修正が完了した
next: reviewers

View File

@ -226,7 +226,7 @@ movements:
- Bash
- WebSearch
- WebFetch
permission_mode: edit
required_permission_mode: edit
instruction: implement
rules:
- condition: 実装完了
@ -309,7 +309,7 @@ movements:
- Bash
- WebSearch
- WebFetch
permission_mode: edit
required_permission_mode: edit
rules:
- condition: 修正完了
next: reviewers

View File

@ -85,7 +85,7 @@ movements:
- Bash
- WebSearch
- WebFetch
permission_mode: edit
required_permission_mode: edit
rules:
- condition: テスト実装完了
next: ai_review
@ -145,7 +145,7 @@ movements:
- Bash
- WebSearch
- WebFetch
permission_mode: edit
required_permission_mode: edit
rules:
- condition: AI問題の修正完了
next: ai_review
@ -212,7 +212,7 @@ movements:
- Bash
- WebSearch
- WebFetch
permission_mode: edit
required_permission_mode: edit
rules:
- condition: 修正完了
next: review_test

View File

@ -51,7 +51,7 @@ movement 内では**キー名**で参照する(パスを直接書かない)
instruction: implement # 指示テンプレートキーinstructions マップを参照、任意)
knowledge: architecture # ナレッジキーknowledge マップを参照、任意)
edit: true # ファイル編集可否(必須)
permission_mode: edit # 権限モード: edit / readonly / full任意
required_permission_mode: edit # 必要最小権限: edit / readonly / full任意
session: refresh # セッション管理(任意)
pass_previous_response: true # 前の出力を渡すか(デフォルト: true
allowed_tools: [...] # 許可ツール一覧(任意、参考情報)

View File

@ -36,7 +36,7 @@ movements:
- name: fix
persona: ../agents/test-coder.md
edit: true
permission_mode: edit
required_permission_mode: edit
instruction_template: |
Fix the issues found in review.
rules:

View File

@ -16,7 +16,7 @@ movements:
- name: step-a
edit: true
persona: ../agents/test-coder.md
permission_mode: edit
required_permission_mode: edit
instruction_template: |
{task}
rules:
@ -26,7 +26,7 @@ movements:
- name: step-b
edit: true
persona: ../agents/test-coder.md
permission_mode: edit
required_permission_mode: edit
instruction_template: |
Continue the task.
rules:

View File

@ -14,7 +14,7 @@ movements:
- name: execute
edit: true
persona: ../agents/test-coder.md
permission_mode: edit
required_permission_mode: edit
instruction_template: |
{task}
rules:

View File

@ -18,7 +18,7 @@ movements:
- Read
- Write
- Edit
permission_mode: edit
required_permission_mode: edit
instruction_template: |
{task}
rules:

View File

@ -16,7 +16,7 @@ movements:
- name: step-1
edit: true
persona: ../agents/test-coder.md
permission_mode: edit
required_permission_mode: edit
instruction_template: |
{task}
rules:
@ -26,7 +26,7 @@ movements:
- name: step-2
edit: true
persona: ../agents/test-coder.md
permission_mode: edit
required_permission_mode: edit
instruction_template: |
Continue the task.
rules:

View File

@ -16,7 +16,7 @@ movements:
- name: plan
persona: ../agents/test-coder.md
edit: true
permission_mode: edit
required_permission_mode: edit
instruction_template: |
Create a plan for the task.
rules:
@ -48,7 +48,7 @@ movements:
- name: fix
persona: ../agents/test-coder.md
edit: true
permission_mode: edit
required_permission_mode: edit
instruction_template: |
Fix the issues found in review.
rules:

View File

@ -18,7 +18,7 @@ movements:
- Read
- Write
- Edit
permission_mode: edit
required_permission_mode: edit
output_contracts:
report:
- Report: report.md

View File

@ -18,7 +18,7 @@ movements:
- Read
- Write
- Edit
permission_mode: edit
required_permission_mode: edit
instruction_template: |
{task}
rules:

View File

@ -14,7 +14,7 @@ movements:
- name: execute
edit: false
persona: ../agents/test-coder.md
permission_mode: readonly
required_permission_mode: readonly
instruction_template: |
Reply with exactly: "Task completed successfully."
Do not do anything else.

View File

@ -81,7 +81,7 @@ describe('E2E: runtime.prepare with provider', () => {
' allowed_tools:',
' - Read',
' - Bash',
' permission_mode: edit',
' required_permission_mode: edit',
' instruction_template: |',
' {task}',
' rules:',

View File

@ -0,0 +1,75 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdirSync, rmSync, writeFileSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { vi } from 'vitest';
const testHomeDir = join(tmpdir(), `takt-gpp-test-${Date.now()}`);
vi.mock('node:os', async () => {
const actual = await vi.importActual('node:os');
return {
...actual,
homedir: () => testHomeDir,
};
});
const { loadGlobalConfig, saveGlobalConfig, invalidateGlobalConfigCache } = await import('../infra/config/global/globalConfig.js');
const { getGlobalConfigPath } = await import('../infra/config/paths.js');
describe('global provider_profiles', () => {
beforeEach(() => {
invalidateGlobalConfigCache();
mkdirSync(testHomeDir, { recursive: true });
});
afterEach(() => {
if (existsSync(testHomeDir)) {
rmSync(testHomeDir, { recursive: true });
}
});
it('loads provider_profiles from yaml', () => {
const taktDir = join(testHomeDir, '.takt');
mkdirSync(taktDir, { recursive: true });
writeFileSync(
getGlobalConfigPath(),
[
'language: en',
'provider_profiles:',
' codex:',
' default_permission_mode: full',
' movement_permission_overrides:',
' ai_fix: edit',
].join('\n'),
'utf-8',
);
const config = loadGlobalConfig();
expect(config.providerProfiles?.codex?.defaultPermissionMode).toBe('full');
expect(config.providerProfiles?.codex?.movementPermissionOverrides?.ai_fix).toBe('edit');
});
it('saves provider_profiles to yaml', () => {
const taktDir = join(testHomeDir, '.takt');
mkdirSync(taktDir, { recursive: true });
writeFileSync(getGlobalConfigPath(), 'language: en\n', 'utf-8');
const config = loadGlobalConfig();
config.providerProfiles = {
codex: {
defaultPermissionMode: 'full',
movementPermissionOverrides: {
supervise: 'full',
},
},
};
saveGlobalConfig(config);
invalidateGlobalConfigCache();
const reloaded = loadGlobalConfig();
expect(reloaded.providerProfiles?.codex?.defaultPermissionMode).toBe('full');
expect(reloaded.providerProfiles?.codex?.movementPermissionOverrides?.supervise).toBe('full');
});
});

View File

@ -84,7 +84,7 @@ describe('PieceConfigRawSchema', () => {
expect(result.max_movements).toBe(10);
});
it('should parse movement with permission_mode', () => {
it('should parse movement with required_permission_mode', () => {
const config = {
name: 'test-piece',
movements: [
@ -92,7 +92,7 @@ describe('PieceConfigRawSchema', () => {
name: 'implement',
persona: 'coder',
allowed_tools: ['Read', 'Edit', 'Write', 'Bash'],
permission_mode: 'edit',
required_permission_mode: 'edit',
instruction: '{task}',
rules: [
{ condition: 'Done', next: 'COMPLETE' },
@ -102,7 +102,7 @@ describe('PieceConfigRawSchema', () => {
};
const result = PieceConfigRawSchema.parse(config);
expect(result.movements![0]?.permission_mode).toBe('edit');
expect(result.movements![0]?.required_permission_mode).toBe('edit');
});
it('should parse movement with provider_options', () => {
@ -177,7 +177,7 @@ describe('PieceConfigRawSchema', () => {
});
});
it('should allow omitting permission_mode', () => {
it('should allow omitting required_permission_mode', () => {
const config = {
name: 'test-piece',
movements: [
@ -190,17 +190,33 @@ describe('PieceConfigRawSchema', () => {
};
const result = PieceConfigRawSchema.parse(config);
expect(result.movements![0]?.permission_mode).toBeUndefined();
expect(result.movements![0]?.required_permission_mode).toBeUndefined();
});
it('should reject invalid permission_mode', () => {
it('should reject invalid required_permission_mode', () => {
const config = {
name: 'test-piece',
movements: [
{
name: 'step1',
persona: 'coder',
permission_mode: 'superAdmin',
required_permission_mode: 'superAdmin',
instruction: '{task}',
},
],
};
expect(() => PieceConfigRawSchema.parse(config)).toThrow();
});
it('should reject legacy permission_mode', () => {
const config = {
name: 'test-piece',
movements: [
{
name: 'step1',
persona: 'coder',
permission_mode: 'edit',
instruction: '{task}',
},
],

View File

@ -3,19 +3,26 @@ import { OptionsBuilder } from '../core/piece/engine/OptionsBuilder.js';
import type { PieceMovement } from '../core/models/types.js';
import type { PieceEngineOptions } from '../core/piece/types.js';
function createMovement(): PieceMovement {
function createMovement(overrides: Partial<PieceMovement> = {}): PieceMovement {
return {
name: 'reviewers',
personaDisplayName: 'Reviewers',
instructionTemplate: 'review',
passPreviousResponse: false,
permissionMode: 'full',
...overrides,
};
}
function createBuilder(step: PieceMovement): OptionsBuilder {
function createBuilder(step: PieceMovement, engineOverrides: Partial<PieceEngineOptions> = {}): OptionsBuilder {
const engineOptions: PieceEngineOptions = {
projectCwd: '/project',
globalProvider: 'codex',
globalProviderProfiles: {
codex: {
defaultPermissionMode: 'full',
},
},
...engineOverrides,
};
return new OptionsBuilder(
@ -31,10 +38,43 @@ function createBuilder(step: PieceMovement): OptionsBuilder {
);
}
describe('OptionsBuilder.buildBaseOptions', () => {
it('resolves permission mode using provider profiles', () => {
const step = createMovement();
const builder = createBuilder(step);
const options = builder.buildBaseOptions(step);
expect(options.permissionMode).toBe('full');
});
it('applies movement requiredPermissionMode as minimum floor', () => {
const step = createMovement({ requiredPermissionMode: 'full' });
const builder = createBuilder(step);
const options = builder.buildBaseOptions(step);
expect(options.permissionMode).toBe('full');
});
it('uses default profile when provider_profiles are not provided', () => {
const step = createMovement();
const builder = createBuilder(step, {
globalProvider: undefined,
globalProviderProfiles: undefined,
projectProvider: undefined,
provider: undefined,
});
const options = builder.buildBaseOptions(step);
expect(options.permissionMode).toBe('edit');
});
});
describe('OptionsBuilder.buildResumeOptions', () => {
it('should enforce readonly permission and empty allowedTools for report/status phases', () => {
// Given
const step = createMovement();
const step = createMovement({ requiredPermissionMode: 'full' });
const builder = createBuilder(step);
// When

View File

@ -0,0 +1,61 @@
import { describe, expect, it } from 'vitest';
import { resolveMovementPermissionMode } from '../core/piece/permission-profile-resolution.js';
describe('resolveMovementPermissionMode', () => {
it('applies required_permission_mode as minimum floor', () => {
const mode = resolveMovementPermissionMode({
movementName: 'implement',
requiredPermissionMode: 'full',
provider: 'codex',
projectProviderProfiles: {
codex: {
defaultPermissionMode: 'readonly',
},
},
});
expect(mode).toBe('full');
});
it('resolves by priority: project override > global override > project default > global default', () => {
const mode = resolveMovementPermissionMode({
movementName: 'supervise',
provider: 'codex',
projectProviderProfiles: {
codex: {
defaultPermissionMode: 'edit',
movementPermissionOverrides: {
supervise: 'full',
},
},
},
globalProviderProfiles: {
codex: {
defaultPermissionMode: 'readonly',
movementPermissionOverrides: {
supervise: 'edit',
},
},
},
});
expect(mode).toBe('full');
});
it('throws when unresolved', () => {
expect(() => resolveMovementPermissionMode({
movementName: 'fix',
provider: 'codex',
})).toThrow(/Unable to resolve permission mode/);
});
it('resolves from required_permission_mode when provider is omitted', () => {
const mode = resolveMovementPermissionMode({
movementName: 'fix',
requiredPermissionMode: 'edit',
});
expect(mode).toBe('edit');
});
});

View File

@ -0,0 +1,62 @@
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdirSync, rmSync, writeFileSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import { tmpdir } from 'node:os';
import { randomUUID } from 'node:crypto';
import { loadProjectConfig, saveProjectConfig } from '../infra/config/project/projectConfig.js';
describe('project provider_profiles', () => {
let testDir: string;
beforeEach(() => {
testDir = join(tmpdir(), `takt-project-profile-${randomUUID()}`);
mkdirSync(testDir, { recursive: true });
});
afterEach(() => {
if (existsSync(testDir)) {
rmSync(testDir, { recursive: true, force: true });
}
});
it('loads provider_profiles from project config', () => {
const taktDir = join(testDir, '.takt');
mkdirSync(taktDir, { recursive: true });
writeFileSync(
join(taktDir, 'config.yaml'),
[
'piece: default',
'provider_profiles:',
' codex:',
' default_permission_mode: full',
' movement_permission_overrides:',
' implement: full',
].join('\n'),
'utf-8',
);
const config = loadProjectConfig(testDir);
expect(config.providerProfiles?.codex?.defaultPermissionMode).toBe('full');
expect(config.providerProfiles?.codex?.movementPermissionOverrides?.implement).toBe('full');
});
it('saves providerProfiles as provider_profiles', () => {
saveProjectConfig(testDir, {
piece: 'default',
providerProfiles: {
codex: {
defaultPermissionMode: 'full',
movementPermissionOverrides: {
fix: 'full',
},
},
},
});
const config = loadProjectConfig(testDir);
expect(config.providerProfiles?.codex?.defaultPermissionMode).toBe('full');
expect(config.providerProfiles?.codex?.movementPermissionOverrides?.fix).toBe('full');
});
});

View File

@ -3,6 +3,7 @@
*/
import type { MovementProviderOptions, PieceRuntimeConfig } from './piece-types.js';
import type { ProviderPermissionProfiles } from './provider-profiles.js';
/** Custom agent configuration */
export interface CustomAgentConfig {
@ -90,6 +91,8 @@ export interface GlobalConfig {
personaProviders?: Record<string, 'claude' | 'codex' | 'opencode' | 'mock'>;
/** 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;
/** Branch name generation strategy: 'romaji' (fast, default) or 'ai' (slow) */
@ -114,4 +117,6 @@ export interface ProjectConfig {
agents?: CustomAgentConfig[];
provider?: 'claude' | 'codex' | 'opencode' | 'mock';
providerOptions?: MovementProviderOptions;
/** Provider-specific permission profiles */
providerProfiles?: ProviderPermissionProfiles;
}

View File

@ -34,6 +34,9 @@ export type {
PipelineConfig,
GlobalConfig,
ProjectConfig,
ProviderProfileName,
ProviderPermissionProfile,
ProviderPermissionProfiles,
} from './types.js';
// Re-export from agent.ts

View File

@ -144,8 +144,8 @@ export interface PieceMovement {
provider?: 'claude' | 'codex' | 'opencode' | 'mock';
/** Model override for this movement */
model?: string;
/** Permission mode for tool execution in this movement */
permissionMode?: PermissionMode;
/** Required minimum permission mode for tool execution in this movement */
requiredPermissionMode?: PermissionMode;
/** Provider-specific movement options */
providerOptions?: MovementProviderOptions;
/** Whether this movement is allowed to edit project files (true=allowed, false=prohibited, undefined=no prompt) */

View File

@ -0,0 +1,19 @@
/**
* Provider-specific permission profile types.
*/
import type { PermissionMode } from './status.js';
/** Supported providers for profile-based permission resolution. */
export type ProviderProfileName = 'claude' | 'codex' | 'opencode' | 'mock';
/** Permission profile for a single provider. */
export interface ProviderPermissionProfile {
/** Default permission mode for movements that do not have an explicit override. */
defaultPermissionMode: PermissionMode;
/** Per-movement permission overrides keyed by movement name. */
movementPermissionOverrides?: Record<string, PermissionMode>;
}
/** Provider -> permission profile map. */
export type ProviderPermissionProfiles = Partial<Record<ProviderProfileName, ProviderPermissionProfile>>;

View File

@ -78,6 +78,23 @@ export const MovementProviderOptionsSchema = z.object({
}).optional(),
}).optional();
/** Provider key schema for profile maps */
export const ProviderProfileNameSchema = z.enum(['claude', 'codex', 'opencode', 'mock']);
/** Provider permission profile schema */
export const ProviderPermissionProfileSchema = z.object({
default_permission_mode: PermissionModeSchema,
movement_permission_overrides: z.record(z.string(), PermissionModeSchema).optional(),
});
/** Provider permission profiles schema */
export const ProviderPermissionProfilesSchema = z.object({
claude: ProviderPermissionProfileSchema.optional(),
codex: ProviderPermissionProfileSchema.optional(),
opencode: ProviderPermissionProfileSchema.optional(),
mock: ProviderPermissionProfileSchema.optional(),
}).optional();
/** Runtime prepare preset identifiers */
export const RuntimePreparePresetSchema = z.enum(['gradle', 'node']);
/** Runtime prepare entry: preset name or script path */
@ -240,7 +257,9 @@ export const ParallelSubMovementRawSchema = z.object({
mcp_servers: McpServersSchema,
provider: z.enum(['claude', 'codex', 'opencode', 'mock']).optional(),
model: z.string().optional(),
permission_mode: PermissionModeSchema.optional(),
/** Removed legacy field (no backward compatibility) */
permission_mode: z.never().optional(),
required_permission_mode: PermissionModeSchema.optional(),
provider_options: MovementProviderOptionsSchema,
edit: z.boolean().optional(),
instruction: z.string().optional(),
@ -271,8 +290,10 @@ export const PieceMovementRawSchema = z.object({
mcp_servers: McpServersSchema,
provider: z.enum(['claude', 'codex', 'opencode', 'mock']).optional(),
model: z.string().optional(),
/** Permission mode for tool execution in this movement */
permission_mode: PermissionModeSchema.optional(),
/** Removed legacy field (no backward compatibility) */
permission_mode: z.never().optional(),
/** Required minimum permission mode for tool execution in this movement */
required_permission_mode: PermissionModeSchema.optional(),
/** Provider-specific movement options */
provider_options: MovementProviderOptionsSchema,
/** Whether this movement is allowed to edit project files */
@ -439,6 +460,8 @@ export const GlobalConfigSchema = z.object({
persona_providers: z.record(z.string(), z.enum(['claude', 'codex', 'opencode', 'mock'])).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,
/** Branch name generation strategy: 'romaji' (fast, default) or 'ai' (slow) */
@ -469,4 +492,5 @@ export const ProjectConfigSchema = z.object({
agents: z.array(CustomAgentConfigSchema).optional(),
provider: z.enum(['claude', 'codex', 'opencode', 'mock']).optional(),
provider_options: MovementProviderOptionsSchema,
provider_profiles: ProviderPermissionProfilesSchema,
});

View File

@ -52,6 +52,14 @@ export type {
PieceState,
} from './piece-types.js';
// Provider permission profiles
export type {
ProviderProfileName,
ProviderPermissionProfile,
ProviderPermissionProfiles,
} from './provider-profiles.js';
// Configuration types (global and project)
export type {
CustomAgentConfig,

View File

@ -5,6 +5,7 @@ import type { PhaseRunnerContext } from '../phase-runner.js';
import type { PieceEngineOptions, PhaseName } from '../types.js';
import { buildSessionKey } from '../session-key.js';
import { resolveMovementProviderModel } from '../provider-resolution.js';
import { DEFAULT_PROVIDER_PERMISSION_PROFILES, resolveMovementPermissionMode } from '../permission-profile-resolution.js';
export class OptionsBuilder {
constructor(
@ -31,6 +32,13 @@ export class OptionsBuilder {
personaProviders: this.engineOptions.personaProviders,
});
const resolvedProviderForPermissions =
this.engineOptions.provider
?? this.engineOptions.projectProvider
?? resolved.provider
?? this.engineOptions.globalProvider
?? 'claude';
return {
cwd: this.getCwd(),
abortSignal: this.engineOptions.abortSignal,
@ -39,7 +47,13 @@ export class OptionsBuilder {
model: this.engineOptions.model,
stepProvider: resolved.provider,
stepModel: resolved.model,
permissionMode: step.permissionMode,
permissionMode: resolveMovementPermissionMode({
movementName: step.name,
requiredPermissionMode: step.requiredPermissionMode,
provider: resolvedProviderForPermissions,
projectProviderProfiles: this.engineOptions.projectProviderProfiles,
globalProviderProfiles: this.engineOptions.globalProviderProfiles ?? DEFAULT_PROVIDER_PERMISSION_PROFILES,
}),
providerOptions: step.providerOptions,
language: this.getLanguage(),
onStream: this.engineOptions.onStream,

View File

@ -59,7 +59,7 @@ function createPartMovement(step: PieceMovement, part: PartDefinition): PieceMov
mcpServers: step.mcpServers,
provider: step.provider,
model: step.model,
permissionMode: step.teamLeader.partPermissionMode ?? step.permissionMode,
requiredPermissionMode: step.teamLeader.partPermissionMode ?? step.requiredPermissionMode,
edit: step.teamLeader.partEdit ?? step.edit,
instructionTemplate: part.instruction,
passPreviousResponse: false,

View File

@ -0,0 +1,88 @@
import type { PermissionMode } from '../models/types.js';
import type { ProviderPermissionProfiles, ProviderProfileName } from '../models/provider-profiles.js';
export interface ResolvePermissionModeInput {
movementName: string;
requiredPermissionMode?: PermissionMode;
provider?: ProviderProfileName;
projectProviderProfiles?: ProviderPermissionProfiles;
globalProviderProfiles?: ProviderPermissionProfiles;
}
export const DEFAULT_PROVIDER_PERMISSION_PROFILES: ProviderPermissionProfiles = {
claude: { defaultPermissionMode: 'edit' },
codex: { defaultPermissionMode: 'edit' },
opencode: { defaultPermissionMode: 'edit' },
mock: { defaultPermissionMode: 'edit' },
};
/**
* Resolve movement permission mode using provider profiles.
*
* Priority:
* 1. project provider_profiles.<provider>.movement_permission_overrides.<movement>
* 2. global provider_profiles.<provider>.movement_permission_overrides.<movement>
* 3. project provider_profiles.<provider>.default_permission_mode
* 4. global provider_profiles.<provider>.default_permission_mode
* 5. apply movement.required_permission_mode as minimum floor
*
* Throws when unresolved.
*/
export function resolveMovementPermissionMode(input: ResolvePermissionModeInput): PermissionMode {
if (!input.provider) {
if (input.requiredPermissionMode) {
return input.requiredPermissionMode;
}
throw new Error(
`Unable to resolve permission mode for movement "${input.movementName}": provider is required when movement.required_permission_mode is omitted.`,
);
}
const projectProfile = input.projectProviderProfiles?.[input.provider];
const globalProfile = input.globalProviderProfiles?.[input.provider];
const projectOverride = projectProfile?.movementPermissionOverrides?.[input.movementName];
if (projectOverride) {
return applyRequiredPermissionFloor(projectOverride, input.requiredPermissionMode);
}
const globalOverride = globalProfile?.movementPermissionOverrides?.[input.movementName];
if (globalOverride) {
return applyRequiredPermissionFloor(globalOverride, input.requiredPermissionMode);
}
if (projectProfile?.defaultPermissionMode) {
return applyRequiredPermissionFloor(projectProfile.defaultPermissionMode, input.requiredPermissionMode);
}
if (globalProfile?.defaultPermissionMode) {
return applyRequiredPermissionFloor(globalProfile.defaultPermissionMode, input.requiredPermissionMode);
}
if (input.requiredPermissionMode) {
return input.requiredPermissionMode;
}
throw new Error(
`Unable to resolve permission mode for movement "${input.movementName}" and provider "${input.provider}": ` +
'define provider_profiles defaults/overrides or movement.required_permission_mode.',
);
}
const PERMISSION_MODE_RANK: Record<PermissionMode, number> = {
readonly: 0,
edit: 1,
full: 2,
};
function applyRequiredPermissionFloor(
resolvedMode: PermissionMode,
requiredMode?: PermissionMode,
): PermissionMode {
if (!requiredMode) {
return resolvedMode;
}
return PERMISSION_MODE_RANK[requiredMode] > PERMISSION_MODE_RANK[resolvedMode]
? requiredMode
: resolvedMode;
}

View File

@ -7,6 +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 { ProviderPermissionProfiles } from '../models/provider-profiles.js';
export type ProviderType = 'claude' | 'codex' | 'opencode' | 'mock';
@ -177,9 +178,17 @@ export interface PieceEngineOptions {
/** Language for instruction metadata. Defaults to 'en'. */
language?: Language;
provider?: ProviderType;
/** Project config provider (used for provider/profile resolution parity with AgentRunner) */
projectProvider?: ProviderType;
/** Global config provider (used for provider/profile resolution parity with AgentRunner) */
globalProvider?: ProviderType;
model?: string;
/** Per-persona provider overrides (e.g., { coder: 'codex' }) */
personaProviders?: Record<string, ProviderType>;
/** Project-level provider permission profiles */
projectProviderProfiles?: ProviderPermissionProfiles;
/** Global-level provider permission profiles */
globalProviderProfiles?: ProviderPermissionProfiles;
/** Enable interactive-only rules and user-input transitions */
interactive?: boolean;
/** Rule tag index detector (required for rules evaluation) */

View File

@ -439,8 +439,12 @@ export async function executePiece(
projectCwd,
language: options.language,
provider: options.provider,
projectProvider: options.projectProvider,
globalProvider: options.globalProvider,
model: options.model,
personaProviders: options.personaProviders,
projectProviderProfiles: options.projectProviderProfiles,
globalProviderProfiles: options.globalProviderProfiles,
interactive: interactiveUserInput,
detectRuleIndex,
callAiJudge,

View File

@ -2,7 +2,7 @@
* Task execution logic
*/
import { loadPieceByIdentifier, isPiecePath, loadGlobalConfig } from '../../../infra/config/index.js';
import { loadPieceByIdentifier, isPiecePath, loadGlobalConfig, loadProjectConfig } from '../../../infra/config/index.js';
import { TaskRunner, type TaskInfo } from '../../../infra/task/index.js';
import {
header,
@ -72,12 +72,17 @@ async function executeTaskWithResult(options: ExecuteTaskOptions): Promise<Piece
});
const globalConfig = loadGlobalConfig();
const projectConfig = loadProjectConfig(projectCwd);
return await executePiece(pieceConfig, task, cwd, {
projectCwd,
language: globalConfig.language,
provider: agentOverrides?.provider,
projectProvider: projectConfig.provider,
globalProvider: globalConfig.provider,
model: agentOverrides?.model,
personaProviders: globalConfig.personaProviders,
projectProviderProfiles: projectConfig.providerProfiles,
globalProviderProfiles: globalConfig.providerProfiles,
interactiveUserInput,
interactiveMetadata,
startMovement,

View File

@ -3,6 +3,7 @@
*/
import type { Language } from '../../../core/models/index.js';
import type { ProviderPermissionProfiles } from '../../../core/models/provider-profiles.js';
import type { ProviderType } from '../../../infra/providers/index.js';
import type { GitHubIssue } from '../../../infra/github/index.js';
@ -31,9 +32,17 @@ export interface PieceExecutionOptions {
/** Language for instruction metadata */
language?: Language;
provider?: ProviderType;
/** Project config provider */
projectProvider?: ProviderType;
/** Global config provider */
globalProvider?: ProviderType;
model?: string;
/** Per-persona provider overrides (e.g., { coder: 'codex' }) */
personaProviders?: Record<string, ProviderType>;
/** Project-level provider permission profiles */
projectProviderProfiles?: ProviderPermissionProfiles;
/** Global-level provider permission profiles */
globalProviderProfiles?: ProviderPermissionProfiles;
/** Enable interactive user input during step transitions */
interactiveUserInput?: boolean;
/** Interactive mode result metadata for NDJSON logging */

View File

@ -9,6 +9,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 { GlobalConfig, DebugConfig, Language } from '../../../core/models/index.js';
import type { ProviderPermissionProfiles } from '../../../core/models/provider-profiles.js';
import { normalizeProviderOptions } from '../loaders/pieceParser.js';
import { getGlobalConfigPath, getProjectConfigPath } from '../paths.js';
import { DEFAULT_LANGUAGE } from '../../../shared/constants.js';
@ -41,6 +42,34 @@ function validateProviderModelCompatibility(provider: string | undefined, model:
}
}
function normalizeProviderProfiles(
raw: Record<string, { default_permission_mode: unknown; movement_permission_overrides?: Record<string, unknown> }> | undefined,
): ProviderPermissionProfiles | undefined {
if (!raw) return undefined;
const entries = Object.entries(raw).map(([provider, profile]) => [provider, {
defaultPermissionMode: profile.default_permission_mode,
movementPermissionOverrides: profile.movement_permission_overrides,
}]);
return Object.fromEntries(entries) as ProviderPermissionProfiles;
}
function denormalizeProviderProfiles(
profiles: ProviderPermissionProfiles | undefined,
): Record<string, { default_permission_mode: string; movement_permission_overrides?: Record<string, string> }> | undefined {
if (!profiles) return undefined;
const entries = Object.entries(profiles);
if (entries.length === 0) return undefined;
return Object.fromEntries(entries.map(([provider, profile]) => [provider, {
default_permission_mode: profile.defaultPermissionMode,
...(profile.movementPermissionOverrides
? { movement_permission_overrides: profile.movementPermissionOverrides }
: {}),
}])) as Record<string, { default_permission_mode: string; movement_permission_overrides?: Record<string, string> }>;
}
/** Create default global configuration (fresh instance each call) */
function createDefaultGlobalConfig(): GlobalConfig {
return {
@ -126,6 +155,7 @@ export class GlobalConfigManager {
pieceCategoriesFile: parsed.piece_categories_file,
personaProviders: parsed.persona_providers,
providerOptions: normalizeProviderOptions(parsed.provider_options),
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,
@ -213,6 +243,10 @@ export class GlobalConfigManager {
if (config.personaProviders && Object.keys(config.personaProviders).length > 0) {
raw.persona_providers = config.personaProviders;
}
const rawProviderProfiles = denormalizeProviderProfiles(config.providerProfiles);
if (rawProviderProfiles && Object.keys(rawProviderProfiles).length > 0) {
raw.provider_profiles = rawProviderProfiles;
}
if (config.runtime?.prepare && config.runtime.prepare.length > 0) {
raw.runtime = {
prepare: [...new Set(config.runtime.prepare)],

View File

@ -310,7 +310,7 @@ function normalizeStepFromRaw(
mcpServers: step.mcp_servers,
provider: step.provider,
model: step.model,
permissionMode: step.permission_mode,
requiredPermissionMode: step.required_permission_mode,
providerOptions: mergeProviderOptions(inheritedProviderOptions, normalizeProviderOptions(step.provider_options)),
edit: step.edit,
instructionTemplate: (step.instruction_template

View File

@ -9,6 +9,7 @@ import { join, resolve } from 'node:path';
import { parse, stringify } from 'yaml';
import { copyProjectResourcesToDir } from '../../resources/index.js';
import type { PermissionMode, ProjectLocalConfig } from '../types.js';
import type { ProviderPermissionProfiles } from '../../../core/models/provider-profiles.js';
export type { PermissionMode, ProjectLocalConfig };
@ -34,6 +35,28 @@ function getConfigPath(projectDir: string): string {
return join(getConfigDir(projectDir), 'config.yaml');
}
function normalizeProviderProfiles(raw: Record<string, { default_permission_mode: unknown; movement_permission_overrides?: Record<string, unknown> }> | undefined): ProviderPermissionProfiles | undefined {
if (!raw) return undefined;
return Object.fromEntries(
Object.entries(raw).map(([provider, profile]) => [provider, {
defaultPermissionMode: profile.default_permission_mode,
movementPermissionOverrides: profile.movement_permission_overrides,
}]),
) as ProviderPermissionProfiles;
}
function denormalizeProviderProfiles(profiles: ProviderPermissionProfiles | undefined): Record<string, { default_permission_mode: string; movement_permission_overrides?: Record<string, string> }> | undefined {
if (!profiles) return undefined;
const entries = Object.entries(profiles);
if (entries.length === 0) return undefined;
return Object.fromEntries(entries.map(([provider, profile]) => [provider, {
default_permission_mode: profile.defaultPermissionMode,
...(profile.movementPermissionOverrides
? { movement_permission_overrides: profile.movementPermissionOverrides }
: {}),
}])) as Record<string, { default_permission_mode: string; movement_permission_overrides?: Record<string, string> }>;
}
/**
* Load project configuration from .takt/config.yaml
*/
@ -46,8 +69,12 @@ export function loadProjectConfig(projectDir: string): ProjectLocalConfig {
try {
const content = readFileSync(configPath, 'utf-8');
const parsed = parse(content) as ProjectLocalConfig | null;
return { ...DEFAULT_PROJECT_CONFIG, ...parsed };
const parsed = (parse(content) as ProjectLocalConfig | null) ?? {};
return {
...DEFAULT_PROJECT_CONFIG,
...parsed,
providerProfiles: normalizeProviderProfiles(parsed.provider_profiles as Record<string, { default_permission_mode: unknown; movement_permission_overrides?: Record<string, unknown> }> | undefined),
};
} catch {
return { ...DEFAULT_PROJECT_CONFIG };
}
@ -68,7 +95,16 @@ export function saveProjectConfig(projectDir: string, config: ProjectLocalConfig
// Copy project resources (only copies files that don't exist)
copyProjectResourcesToDir(configDir);
const content = stringify(config, { indent: 2 });
const savePayload: ProjectLocalConfig = { ...config };
const rawProfiles = denormalizeProviderProfiles(config.providerProfiles);
if (rawProfiles && Object.keys(rawProfiles).length > 0) {
savePayload.provider_profiles = rawProfiles;
} else {
delete savePayload.provider_profiles;
}
delete savePayload.providerProfiles;
const content = stringify(savePayload, { indent: 2 });
writeFileSync(configPath, content, 'utf-8');
}

View File

@ -4,6 +4,7 @@
import type { PieceCategoryConfigNode } from '../../core/models/schemas.js';
import type { MovementProviderOptions } from '../../core/models/piece-types.js';
import type { ProviderPermissionProfiles } from '../../core/models/provider-profiles.js';
/** Permission mode for the project
* - default: Uses Agent SDK's acceptEdits mode (auto-accepts file edits, minimal prompts)
@ -25,6 +26,10 @@ export interface ProjectLocalConfig {
verbose?: boolean;
/** Provider-specific options (overrides global, overridden by piece/movement) */
provider_options?: MovementProviderOptions;
/** Provider-specific permission profiles (project-level override) */
provider_profiles?: ProviderPermissionProfiles;
/** Provider-specific permission profiles (camelCase alias) */
providerProfiles?: ProviderPermissionProfiles;
/** Piece categories (name -> piece list) */
piece_categories?: Record<string, PieceCategoryConfigNode>;
/** Show uncategorized pieces under Others category */