Merge branch 'develop' of https://github.com/nrslib/takt into develop

This commit is contained in:
nrslib 2026-02-13 22:41:10 +09:00
commit fcabcd94e4
49 changed files with 1380 additions and 343 deletions

View File

@ -4,12 +4,13 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [0.13.0-alpha.1] - 2026-02-13
## [0.13.0] - 2026-02-13
### Added
- **Team Leader ムーブメント**: ムーブメント内でチームリーダーエージェントがタスクを動的にサブタスクPartへ分解し、複数のパートエージェントを並列実行する新しいムーブメントタイプ — `team_leader` 設定persona, maxParts, timeoutMs, partPersona, partEdit, partPermissionModeをサポート (#244)
- **構造化出力Structured Output**: エージェント呼び出しに JSON Schema ベースの構造化出力を導入 — タスク分解decomposition、ルール評価evaluation、ステータス判定judgmentの3つのスキーマを `builtins/schemas/` に追加。Claude / Codex 両プロバイダーで対応 (#257)
- **`provider_options` ピースレベル設定**: ピース全体(`piece_config.provider_options`)および個別ムーブメントにプロバイダー固有オプション(`codex.network_access``opencode.network_access`)を設定可能に — 全ビルトインピースに Codex/OpenCode のネットワークアクセスを有効化
- **`backend` ビルトインピース**: バックエンド開発特化のピースを新規追加 — バックエンド、セキュリティ、QA の並列専門家レビュー対応
- **`backend-cqrs` ビルトインピース**: CQRS+ES 特化のバックエンド開発ピースを新規追加 — CQRS+ES、セキュリティ、QA の並列専門家レビュー対応
- **AbortSignal によるパートタイムアウト**: Team Leader のパート実行にタイムアウト制御と親シグナル連動の AbortSignal を追加
@ -21,6 +22,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
- **Phase 3 判定ロジックの刷新**: `JudgmentDetector` / `FallbackStrategy` を廃止し、構造化出力ベースの `status-judgment-phase.ts` に統合。判定の安定性と保守性を向上 (#257)
- **Report フェーズのリトライ改善**: Report PhasePhase 2が失敗した場合、新規セッションで自動リトライするよう改善 (#245)
- **Ctrl+C シャットダウンの統一**: `sigintHandler.ts` を廃止し、`ShutdownManager` に統合 — グレースフルシャットダウン → タイムアウト → 強制終了の3段階制御を全プロバイダーで共通化 (#237)
- **スコープ外削除の防止ガードレール**: coder ペルソナにタスク指示書の範囲外の削除・構造変更を禁止するルールを追加。planner ペルソナにスコープ規律と参照資料の優先順位を追加
- フロントエンドナレッジにデザイントークンとテーマスコープのガイダンスを追加
- アーキテクチャナレッジの改善en/ja 両対応)
@ -39,6 +41,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
- AbortSignal のユニットテスト追加abort-signal, claude-executor-abort-signal, claude-provider-abort-signal
- Report Phase リトライのユニットテスト追加report-phase-retry
- パブリック API エクスポートのユニットテスト追加public-api-exports
- provider_options 関連のテスト追加provider-options-piece-parser, models, opencode-types
- E2E テストの大幅拡充: cycle-detection, model-override, multi-step-sequential, pipeline-local-repo, report-file-output, run-sigint-graceful, session-log, structured-output, task-status-persistence
- E2E テストヘルパーのリファクタリング(共通 setup 関数の抽出)
- `judgment/` ディレクトリJudgmentDetector, FallbackStrategyを削除

2
OPENCODE_CONFIG_CONTENT Normal file
View File

@ -0,0 +1,2 @@
{
"$schema": "https://opencode.ai/config.json","model":"zai-coding-plan/glm-5","small_model":"zai-coding-plan/glm-5","permission":"deny"}

View File

@ -35,3 +35,4 @@ You are the implementer. Focus on implementation, not design decisions.
- Adding backward compatibility or legacy support without being asked → Absolutely prohibited
- Leaving replaced code/exports after refactoring → Prohibited (remove unless explicitly told to keep)
- Layering workarounds that bypass safety mechanisms on top of a root cause fix → Prohibited
- Deleting existing features or structural changes not in the task order as a "side effect" → Prohibited (report even if included in the plan, when there's no basis in the task order for large-scale deletions)

View File

@ -50,6 +50,23 @@ Judge from a big-picture perspective to avoid "missing the forest for the trees.
| Non-functional Requirements | Are performance, security, etc. met? |
| Scope | Is there scope creep beyond requirements? |
### Scope Creep Detection (Deletions are Critical)
File **deletions** and removal of existing features are the most dangerous form of scope creep.
Additions can be reverted, but restoring deleted flows is difficult.
**Required steps:**
1. List all deleted files (D) and deleted classes/methods/endpoints from the diff
2. Cross-reference each deletion against the task order to find its justification
3. REJECT any deletion that has no basis in the task order
**Typical scope creep patterns:**
- A "change statuses" task includes wholesale deletion of Sagas or endpoints
- A "UI fix" task includes structural changes to backend domain models
- A "display change" task rewrites business logic flows
Even if reviewers approved a deletion as "sound design," REJECT it if it's outside the task order scope.
### 3. Risk Assessment
**Risk Matrix:**

View File

@ -86,11 +86,22 @@ Based on investigation and design, determine the implementation direction:
- Points to be careful about
- Spec constraints
## Scope Discipline
Only plan work that is explicitly stated in the task order. Do not include implicit "improvements."
**Deletion criteria:**
- **Code made newly unused by this task's changes** → OK to plan deletion (e.g., renamed old variable)
- **Existing features, flows, endpoints, Sagas, events** → Do NOT delete unless explicitly instructed in the task order
"Change statuses to 5 values" means "rewrite enum values," NOT "delete flows that seem unnecessary."
Do not over-interpret the task order. Plan only what is written.
## Design Principles
**Backward Compatibility:**
- Do not include backward compatibility code unless explicitly instructed
- Plan to delete things that are unused
- Delete code that was made newly unused by this task's changes
**Don't Generate Unnecessary Code:**
- Don't plan "just in case" code, future fields, or unused methods

View File

@ -100,6 +100,21 @@ Check:
**REJECT if spec violations are found.** Don't assume "probably correct"—actually read and cross-reference the specs.
### Scope Creep Detection (Deletions are Critical)
File **deletions** and removal of existing features are the most dangerous form of scope creep.
Additions can be reverted, but restoring deleted flows is difficult.
**Required steps:**
1. List all deleted files (D) and deleted classes/methods/endpoints from the diff
2. Cross-reference each deletion against the task order to find its justification
3. REJECT any deletion that has no basis in the task order
**Typical scope creep patterns:**
- A "change statuses" task includes wholesale deletion of Sagas or endpoints
- 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.**
@ -115,7 +130,7 @@ Check:
| 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 | Record only - Address in next task |
| Scope creep | REJECT - Deletions outside task order must be reverted |
### 9. Improvement Suggestion Check

View File

@ -35,3 +35,4 @@
- 後方互換・Legacy 対応を勝手に追加する → 絶対禁止
- リファクタリングで置き換えたコード・エクスポートを残す → 禁止(明示的に残すよう指示されない限り削除する)
- 根本原因を修正した上で安全機構を迂回するワークアラウンドを重ねる → 禁止
- タスク指示書にない既存機能の削除・構造変更を「ついでに」行う → 禁止(計画に含まれていても、指示書に根拠がない大規模削除は報告する)

View File

@ -43,6 +43,23 @@
| 非機能要件 | パフォーマンス、セキュリティ等は満たされているか |
| スコープ | 要求以上のことをしていないか(スコープクリープ) |
### スコープクリープの検出(削除は最重要チェック)
ファイルの**削除**と既存機能の**除去**はスコープクリープの最も危険な形態。
追加は元に戻せるが、削除されたフローの復元は困難。
**必須手順:**
1. 変更差分から削除されたファイルDと削除されたクラス・メソッド・エンドポイントを列挙する
2. 各削除がタスク指示書のどの項目に対応するかを照合する
3. タスク指示書に根拠がない削除は REJECT する
**典型的なスコープクリープ:**
- 「ステータス変更」タスクで Saga やエンドポイントが丸ごと削除されている
- 「UI修正」タスクでバックエンドのドメインモデルが構造変更されている
- 「表示変更」タスクでビジネスロジックのフローが書き換えられている
レビュアーが「設計判断として妥当」と承認していても、タスク指示書のスコープ外であれば REJECT する。
### リスク評価
| 影響度\発生確率 | 低 | 中 | 高 |

View File

@ -64,8 +64,19 @@
- 循環依存を作らない
- 責務の分離(読み取りと書き込み、ビジネスロジックと IO
### スコープ規律
タスク指示書に明記された作業のみを計画する。暗黙の「改善」を勝手に含めない。
**削除の判断基準:**
- **今回の変更で新たに未使用になったコード** → 削除を計画してよい(例: リネームした旧変数)
- **既存の機能・フロー・エンドポイント・Saga・イベント** → タスク指示書で明示的に指示されない限り削除しない
「ステータスを5つに変更する」は「enum値を書き換える」であり、「不要になったフローを丸ごと削除する」ではない。
タスク指示書の文言を拡大解釈しない。書かれていることだけを計画する。
### 計画の原則
- 後方互換コードは計画に含めない(明示的な指示がない限り不要)
- 使われていないものは削除する計画を立てる
- 今回の変更で新たに未使用になったコードは削除する計画を立てる
- TODO コメントで済ませる計画は立てない。今やるか、やらないか

View File

@ -102,6 +102,21 @@
「機能的に無害」は免罪符ではない。修正コストがほぼゼロの指摘を「非ブロッキング」「次回タスク」に分類することは妥協である。レビュアーが発見し、数分以内に修正できる問題は今回のタスクで修正させる。
### スコープクリープの検出(削除は最重要チェック)
ファイルの**削除**と既存機能の**除去**はスコープクリープの最も危険な形態。
追加は元に戻せるが、削除されたフローの復元は困難。
**必須手順:**
1. 変更差分から削除されたファイルDと削除されたクラス・メソッド・エンドポイントを列挙する
2. 各削除がタスク指示書のどの項目に対応するかを照合する
3. タスク指示書に根拠がない削除は REJECT する
**典型的なスコープクリープ:**
- 「ステータス変更」タスクで Saga やエンドポイントが丸ごと削除されている
- 「UI修正」タスクでバックエンドのドメインモデルが構造変更されている
- 「表示変更」タスクでビジネスロジックのフローが書き換えられている
### ピース全体の見直し
レポートディレクトリ内の全レポートを確認し、ピース全体の整合性をチェックする。

168
docs/provider-sandbox.md Normal file
View File

@ -0,0 +1,168 @@
# Provider Sandbox Configuration
TAKT supports configuring sandbox settings for AI agent providers. This document covers how sandbox isolation works across providers, how to configure it, and the security trade-offs.
## Overview
| Provider | Sandbox Mechanism | Build Tool Issues | TAKT Configuration |
|----------|------------------|-------------------|-------------------|
| **Claude Code** | macOS Seatbelt / Linux bubblewrap | Gradle/JVM blocked in `edit` mode | `provider_options.claude.sandbox` |
| **Codex CLI** | macOS Seatbelt / Linux Landlock+seccomp | npm/maven/pytest failures (widespread) | `provider_options.codex.network_access` |
| **OpenCode CLI** | None (no native sandbox) | No constraints (no security either) | N/A |
## Claude Code Sandbox
### The Problem
When a movement uses `permission_mode: edit` (mapped to Claude SDK's `acceptEdits`), Bash commands run inside a macOS Seatbelt sandbox. This sandbox blocks:
- Writes outside the working directory (e.g., `~/.gradle`)
- Certain system calls required by JVM initialization
- Network access (by default)
As a result, build tools like Gradle, Maven, or any JVM-based tool fail with `Operation not permitted`.
### Solution: `provider_options.claude.sandbox`
TAKT exposes Claude SDK's `SandboxSettings` through `provider_options.claude.sandbox` at four configuration levels.
#### Option A: `allow_unsandboxed_commands` (Recommended)
Allow all Bash commands to run outside the sandbox while keeping file edit permissions controlled:
```yaml
provider_options:
claude:
sandbox:
allow_unsandboxed_commands: true
```
#### Option B: `excluded_commands`
Exclude only specific commands from the sandbox:
```yaml
provider_options:
claude:
sandbox:
excluded_commands:
- ./gradlew
- npm
- npx
```
### Configuration Levels
Settings are merged with the following priority (highest wins):
```
Movement > Piece > Project Local > Global
```
#### Global (`~/.takt/config.yaml`)
Applies to all projects and all pieces:
```yaml
# ~/.takt/config.yaml
provider_options:
claude:
sandbox:
allow_unsandboxed_commands: true
```
#### Project Local (`.takt/config.yaml`)
Applies to this project only:
```yaml
# .takt/config.yaml
provider_options:
claude:
sandbox:
excluded_commands:
- ./gradlew
```
#### Piece (`piece_config` section)
Applies to all movements in this piece:
```yaml
# pieces/my-piece.yaml
piece_config:
provider_options:
claude:
sandbox:
allow_unsandboxed_commands: true
```
#### Movement (per step)
Applies to a specific movement only:
```yaml
movements:
- name: implement
permission_mode: edit
provider_options:
claude:
sandbox:
allow_unsandboxed_commands: true
- name: review
permission_mode: readonly
# No sandbox config needed — readonly doesn't sandbox Bash
```
### Security Risk Comparison
| Configuration | File Edits | Network | Bash Commands | CWD-external Writes | Risk Level |
|--------------|-----------|---------|---------------|---------------------|------------|
| `permission_mode: edit` (default) | Permitted | Blocked | Sandboxed | Blocked | Low |
| `excluded_commands: [./gradlew]` | Permitted | Blocked | Only `./gradlew` unsandboxed | Only via `./gradlew` | Low |
| `allow_unsandboxed_commands: true` | Permitted | Allowed | Unsandboxed | Allowed via Bash | **Medium** |
| `permission_mode: full` | All permitted | Allowed | Unsandboxed | All permitted | **High** |
**Key difference between `allow_unsandboxed_commands` and `permission_mode: full`:**
- `allow_unsandboxed_commands`: File edits still require Claude Code's permission check (`acceptEdits` mode). Only Bash is unsandboxed.
- `permission_mode: full`: All permission checks are bypassed (`bypassPermissions` mode). No guardrails at all.
### Practical Risk Assessment
The "Medium" risk of `allow_unsandboxed_commands` is manageable in practice because:
- TAKT runs locally on the developer's machine (not a public-facing service)
- Input comes from task instructions written by the developer
- Agent behavior is reviewed by the supervisor movement
- File edit operations still go through Claude Code's permission system
## Codex CLI Sandbox
Codex CLI uses macOS Seatbelt (same as Claude Code) but has **more severe compatibility issues** with build tools. Community reports show npm, Maven, pytest, and other tools frequently failing with `Operation not permitted` — even when the same commands work in Claude Code.
Codex sandbox is configured via `~/.codex/config.toml` (not through TAKT):
```toml
# ~/.codex/config.toml
sandbox_mode = "workspace-write"
[sandbox_workspace_write]
network_access = true
writable_roots = ["/Users/YOU/.gradle"]
```
TAKT provides `provider_options.codex.network_access` to control network access via the Codex SDK:
```yaml
provider_options:
codex:
network_access: true
```
For other sandbox settings (writable_roots, sandbox_mode), configure directly in `~/.codex/config.toml`.
## OpenCode CLI Sandbox
OpenCode CLI does not have a native sandbox mechanism. All commands run without filesystem or network restrictions. For isolation, the community recommends Docker containers (e.g., [opencode-sandbox](https://github.com/fabianlema/opencode-sandbox)).
No TAKT-side sandbox configuration is needed or available for OpenCode.

View File

@ -123,6 +123,7 @@ export function createIsolatedEnv(): IsolatedEnv {
TAKT_CONFIG_DIR: taktDir,
GIT_CONFIG_GLOBAL: gitConfigPath,
TAKT_NO_TTY: '1',
TAKT_NOTIFY_WEBHOOK: undefined,
};
return {

4
package-lock.json generated
View File

@ -1,12 +1,12 @@
{
"name": "takt",
"version": "0.13.0-alpha.1",
"version": "0.13.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "takt",
"version": "0.13.0-alpha.1",
"version": "0.13.0",
"license": "MIT",
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.2.37",

View File

@ -1,6 +1,6 @@
{
"name": "takt",
"version": "0.13.0-alpha.1",
"version": "0.13.0",
"description": "TAKT: TAKT Agent Koordination Topology - AI Agent Piece Orchestration",
"main": "dist/index.js",
"types": "dist/index.d.ts",

View File

@ -0,0 +1,39 @@
import { describe, it, expect, vi } from 'vitest';
import { dispatchConversationAction } from '../features/interactive/actionDispatcher.js';
describe('dispatchConversationAction', () => {
it('should dispatch to matching handler with full result payload', async () => {
const execute = vi.fn().mockResolvedValue('executed');
const saveTask = vi.fn().mockResolvedValue('saved');
const cancel = vi.fn().mockResolvedValue('cancelled');
const result = await dispatchConversationAction(
{ action: 'save_task', task: 'refine branch docs' },
{
execute,
save_task: saveTask,
cancel,
},
);
expect(result).toBe('saved');
expect(saveTask).toHaveBeenCalledWith({ action: 'save_task', task: 'refine branch docs' });
expect(execute).not.toHaveBeenCalled();
expect(cancel).not.toHaveBeenCalled();
});
it('should support synchronous handlers', async () => {
const result = await dispatchConversationAction(
{ action: 'cancel', task: '' },
{
execute: () => true,
save_task: () => true,
cancel: () => false,
},
);
expect(result).toBe(false);
});
});

View File

@ -0,0 +1,282 @@
/**
* Tests for instruct mode
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
vi.mock('../infra/config/global/globalConfig.js', () => ({
loadGlobalConfig: vi.fn(() => ({ provider: 'mock', language: 'en' })),
getBuiltinPiecesEnabled: vi.fn().mockReturnValue(true),
}));
vi.mock('../infra/providers/index.js', () => ({
getProvider: vi.fn(),
}));
vi.mock('../shared/utils/index.js', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
createLogger: () => ({
info: vi.fn(),
debug: vi.fn(),
error: vi.fn(),
}),
}));
vi.mock('../shared/context.js', () => ({
isQuietMode: vi.fn(() => false),
}));
vi.mock('../infra/config/paths.js', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
loadPersonaSessions: vi.fn(() => ({})),
updatePersonaSession: vi.fn(),
getProjectConfigDir: vi.fn(() => '/tmp'),
loadSessionState: vi.fn(() => null),
clearSessionState: vi.fn(),
}));
vi.mock('../shared/ui/index.js', () => ({
info: vi.fn(),
error: vi.fn(),
blankLine: vi.fn(),
StreamDisplay: vi.fn().mockImplementation(() => ({
createHandler: vi.fn(() => vi.fn()),
flush: vi.fn(),
})),
}));
vi.mock('../shared/prompt/index.js', () => ({
selectOption: vi.fn(),
}));
vi.mock('../shared/i18n/index.js', () => ({
getLabel: vi.fn((_key: string, _lang: string) => 'Mock label'),
getLabelObject: vi.fn(() => ({
intro: 'Instruct mode intro',
resume: 'Resuming',
noConversation: 'No conversation',
summarizeFailed: 'Summarize failed',
continuePrompt: 'Continue',
proposed: 'Proposed task:',
actionPrompt: 'What to do?',
actions: {
execute: 'Execute',
saveTask: 'Save task',
continue: 'Continue',
},
cancelled: 'Cancelled',
})),
}));
vi.mock('../shared/prompts/index.js', () => ({
loadTemplate: vi.fn((_name: string, _lang: string) => 'Mock template content'),
}));
import { getProvider } from '../infra/providers/index.js';
import { runInstructMode } from '../features/tasks/list/instructMode.js';
import { selectOption } from '../shared/prompt/index.js';
import { info } from '../shared/ui/index.js';
const mockGetProvider = vi.mocked(getProvider);
const mockSelectOption = vi.mocked(selectOption);
const mockInfo = vi.mocked(info);
let savedIsTTY: boolean | undefined;
let savedIsRaw: boolean | undefined;
let savedSetRawMode: typeof process.stdin.setRawMode | undefined;
let savedStdoutWrite: typeof process.stdout.write;
let savedStdinOn: typeof process.stdin.on;
let savedStdinRemoveListener: typeof process.stdin.removeListener;
let savedStdinResume: typeof process.stdin.resume;
let savedStdinPause: typeof process.stdin.pause;
function setupRawStdin(rawInputs: string[]): void {
savedIsTTY = process.stdin.isTTY;
savedIsRaw = process.stdin.isRaw;
savedSetRawMode = process.stdin.setRawMode;
savedStdoutWrite = process.stdout.write;
savedStdinOn = process.stdin.on;
savedStdinRemoveListener = process.stdin.removeListener;
savedStdinResume = process.stdin.resume;
savedStdinPause = process.stdin.pause;
Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true });
Object.defineProperty(process.stdin, 'isRaw', { value: false, configurable: true, writable: true });
process.stdin.setRawMode = vi.fn((mode: boolean) => {
(process.stdin as unknown as { isRaw: boolean }).isRaw = mode;
return process.stdin;
}) as unknown as typeof process.stdin.setRawMode;
process.stdout.write = vi.fn(() => true) as unknown as typeof process.stdout.write;
process.stdin.resume = vi.fn(() => process.stdin) as unknown as typeof process.stdin.resume;
process.stdin.pause = vi.fn(() => process.stdin) as unknown as typeof process.stdin.pause;
let currentHandler: ((data: Buffer) => void) | null = null;
let inputIndex = 0;
process.stdin.on = vi.fn(((event: string, handler: (...args: unknown[]) => void) => {
if (event === 'data') {
currentHandler = handler as (data: Buffer) => void;
if (inputIndex < rawInputs.length) {
const data = rawInputs[inputIndex]!;
inputIndex++;
queueMicrotask(() => {
if (currentHandler) {
currentHandler(Buffer.from(data, 'utf-8'));
}
});
}
}
return process.stdin;
}) as typeof process.stdin.on);
process.stdin.removeListener = vi.fn(((event: string) => {
if (event === 'data') {
currentHandler = null;
}
return process.stdin;
}) as typeof process.stdin.removeListener);
}
function restoreStdin(): void {
if (savedIsTTY !== undefined) {
Object.defineProperty(process.stdin, 'isTTY', { value: savedIsTTY, configurable: true });
}
if (savedIsRaw !== undefined) {
Object.defineProperty(process.stdin, 'isRaw', { value: savedIsRaw, configurable: true, writable: true });
}
if (savedSetRawMode) {
process.stdin.setRawMode = savedSetRawMode;
}
if (savedStdoutWrite) {
process.stdout.write = savedStdoutWrite;
}
if (savedStdinOn) {
process.stdin.on = savedStdinOn;
}
if (savedStdinRemoveListener) {
process.stdin.removeListener = savedStdinRemoveListener;
}
if (savedStdinResume) {
process.stdin.resume = savedStdinResume;
}
if (savedStdinPause) {
process.stdin.pause = savedStdinPause;
}
}
function toRawInputs(inputs: (string | null)[]): string[] {
return inputs.map((input) => {
if (input === null) return '\x04';
return input + '\r';
});
}
function setupMockProvider(responses: string[]): void {
let callIndex = 0;
const mockCall = vi.fn(async () => {
const content = callIndex < responses.length ? responses[callIndex] : 'AI response';
callIndex++;
return {
persona: 'instruct',
status: 'done' as const,
content: content!,
timestamp: new Date(),
};
});
const mockProvider = {
setup: () => ({ call: mockCall }),
_call: mockCall,
};
mockGetProvider.mockReturnValue(mockProvider);
}
beforeEach(() => {
vi.clearAllMocks();
mockSelectOption.mockResolvedValue('execute');
});
afterEach(() => {
restoreStdin();
});
describe('runInstructMode', () => {
it('should return action=cancel when user types /cancel', async () => {
setupRawStdin(toRawInputs(['/cancel']));
setupMockProvider([]);
const result = await runInstructMode('/project', 'branch context', 'feature-branch');
expect(result.action).toBe('cancel');
expect(result.task).toBe('');
});
it('should include branch name in intro message', async () => {
setupRawStdin(toRawInputs(['/cancel']));
setupMockProvider([]);
await runInstructMode('/project', 'diff stats', 'my-feature-branch');
const introCall = mockInfo.mock.calls.find((call) =>
call[0]?.includes('my-feature-branch')
);
expect(introCall).toBeDefined();
});
it('should return action=execute with task on /go after conversation', async () => {
setupRawStdin(toRawInputs(['add more tests', '/go']));
setupMockProvider(['What kind of tests?', 'Add unit tests for the feature.']);
const result = await runInstructMode('/project', 'branch context', 'feature-branch');
expect(result.action).toBe('execute');
expect(result.task).toBe('Add unit tests for the feature.');
});
it('should return action=save_task when user selects save task', async () => {
setupRawStdin(toRawInputs(['describe task', '/go']));
setupMockProvider(['response', 'Summarized task.']);
mockSelectOption.mockResolvedValue('save_task');
const result = await runInstructMode('/project', 'branch context', 'feature-branch');
expect(result.action).toBe('save_task');
expect(result.task).toBe('Summarized task.');
});
it('should continue editing when user selects continue', async () => {
setupRawStdin(toRawInputs(['describe task', '/go', '/cancel']));
setupMockProvider(['response', 'Summarized task.']);
mockSelectOption.mockResolvedValueOnce('continue');
const result = await runInstructMode('/project', 'branch context', 'feature-branch');
expect(result.action).toBe('cancel');
});
it('should reject /go with no prior conversation', async () => {
setupRawStdin(toRawInputs(['/go', '/cancel']));
setupMockProvider([]);
const result = await runInstructMode('/project', 'branch context', 'feature-branch');
expect(result.action).toBe('cancel');
});
it('should use custom action selector without create_issue option', async () => {
setupRawStdin(toRawInputs(['task', '/go']));
setupMockProvider(['response', 'Task summary.']);
await runInstructMode('/project', 'branch context', 'feature-branch');
const selectCall = mockSelectOption.mock.calls.find((call) =>
Array.isArray(call[1])
);
expect(selectCall).toBeDefined();
const options = selectCall![1] as Array<{ value: string }>;
const values = options.map((o) => o.value);
expect(values).toContain('execute');
expect(values).toContain('save_task');
expect(values).toContain('continue');
expect(values).not.toContain('create_issue');
});
});

View File

@ -33,7 +33,19 @@ vi.mock('../infra/config/index.js', async (importOriginal) => {
return actual;
});
const { selectPieceFromEntries, selectPieceFromCategorizedPieces } = await import('../features/pieceSelection/index.js');
const configMock = vi.hoisted(() => ({
listPieces: vi.fn(),
listPieceEntries: vi.fn(),
loadAllPiecesWithSources: vi.fn(),
getPieceCategories: vi.fn(),
buildCategorizedPieces: vi.fn(),
getCurrentPiece: vi.fn(),
findPieceCategories: vi.fn(() => []),
}));
vi.mock('../infra/config/index.js', () => configMock);
const { selectPieceFromEntries, selectPieceFromCategorizedPieces, selectPiece } = await import('../features/pieceSelection/index.js');
describe('selectPieceFromEntries', () => {
beforeEach(() => {
@ -231,3 +243,93 @@ describe('selectPieceFromCategorizedPieces', () => {
expect(labels.some((l) => l.includes('Dev'))).toBe(false);
});
});
describe('selectPiece', () => {
const entries: PieceDirEntry[] = [
{ name: 'custom-flow', path: '/tmp/custom.yaml', source: 'user' },
{ name: 'builtin-flow', path: '/tmp/builtin.yaml', source: 'builtin' },
];
beforeEach(() => {
selectOptionMock.mockReset();
bookmarkState.bookmarks = [];
configMock.listPieces.mockReset();
configMock.listPieceEntries.mockReset();
configMock.loadAllPiecesWithSources.mockReset();
configMock.getPieceCategories.mockReset();
configMock.buildCategorizedPieces.mockReset();
configMock.getCurrentPiece.mockReset();
});
it('should return default piece when no pieces found and fallbackToDefault is true', async () => {
configMock.getPieceCategories.mockReturnValue(null);
configMock.listPieces.mockReturnValue([]);
configMock.getCurrentPiece.mockReturnValue('default');
const result = await selectPiece('/cwd');
expect(result).toBe('default');
});
it('should return null when no pieces found and fallbackToDefault is false', async () => {
configMock.getPieceCategories.mockReturnValue(null);
configMock.listPieces.mockReturnValue([]);
configMock.getCurrentPiece.mockReturnValue('default');
const result = await selectPiece('/cwd', { fallbackToDefault: false });
expect(result).toBeNull();
});
it('should prompt selection even when only one piece exists', async () => {
configMock.getPieceCategories.mockReturnValue(null);
configMock.listPieces.mockReturnValue(['only-piece']);
configMock.listPieceEntries.mockReturnValue([
{ name: 'only-piece', path: '/tmp/only-piece.yaml', source: 'user' },
]);
configMock.getCurrentPiece.mockReturnValue('only-piece');
selectOptionMock.mockResolvedValueOnce('only-piece');
const result = await selectPiece('/cwd');
expect(result).toBe('only-piece');
expect(selectOptionMock).toHaveBeenCalled();
});
it('should use category-based selection when category config exists', async () => {
const pieceMap = createPieceMap([{ name: 'my-piece', source: 'user' }]);
const categorized: CategorizedPieces = {
categories: [{ name: 'Dev', pieces: ['my-piece'], children: [] }],
allPieces: pieceMap,
missingPieces: [],
};
configMock.getPieceCategories.mockReturnValue({ categories: ['Dev'] });
configMock.loadAllPiecesWithSources.mockReturnValue(pieceMap);
configMock.buildCategorizedPieces.mockReturnValue(categorized);
configMock.getCurrentPiece.mockReturnValue('my-piece');
selectOptionMock.mockResolvedValueOnce('__current__');
const result = await selectPiece('/cwd');
expect(result).toBe('my-piece');
expect(configMock.buildCategorizedPieces).toHaveBeenCalled();
});
it('should use directory-based selection when no category config', async () => {
configMock.getPieceCategories.mockReturnValue(null);
configMock.listPieces.mockReturnValue(['piece-a', 'piece-b']);
configMock.listPieceEntries.mockReturnValue(entries);
configMock.getCurrentPiece.mockReturnValue('piece-a');
selectOptionMock
.mockResolvedValueOnce('custom')
.mockResolvedValueOnce('custom-flow');
const result = await selectPiece('/cwd');
expect(result).toBe('custom-flow');
expect(configMock.listPieceEntries).toHaveBeenCalled();
});
});

View File

@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { normalizePieceConfig } from '../infra/config/loaders/pieceParser.js';
import { normalizePieceConfig, mergeProviderOptions } from '../infra/config/loaders/pieceParser.js';
describe('normalizePieceConfig provider_options', () => {
it('piece-level global を movement に継承し、movement 側で上書きできる', () => {
@ -43,4 +43,78 @@ describe('normalizePieceConfig provider_options', () => {
opencode: { networkAccess: false },
});
});
it('claude sandbox を piece-level で設定し movement で上書きできる', () => {
const raw = {
name: 'claude-sandbox',
piece_config: {
provider_options: {
claude: {
sandbox: { allow_unsandboxed_commands: true },
},
},
},
movements: [
{
name: 'inherit',
instruction: '{task}',
},
{
name: 'override',
provider_options: {
claude: {
sandbox: {
allow_unsandboxed_commands: false,
excluded_commands: ['./gradlew'],
},
},
},
instruction: '{task}',
},
],
};
const config = normalizePieceConfig(raw, process.cwd());
expect(config.providerOptions).toEqual({
claude: { sandbox: { allowUnsandboxedCommands: true } },
});
expect(config.movements[0]?.providerOptions).toEqual({
claude: { sandbox: { allowUnsandboxedCommands: true } },
});
expect(config.movements[1]?.providerOptions).toEqual({
claude: {
sandbox: {
allowUnsandboxedCommands: false,
excludedCommands: ['./gradlew'],
},
},
});
});
});
describe('mergeProviderOptions', () => {
it('複数層を正しくマージする(後の層が優先)', () => {
const global = {
claude: { sandbox: { allowUnsandboxedCommands: false, excludedCommands: ['./gradlew'] } },
codex: { networkAccess: true },
};
const local = {
claude: { sandbox: { allowUnsandboxedCommands: true } },
};
const step = {
codex: { networkAccess: false },
};
const result = mergeProviderOptions(global, local, step);
expect(result).toEqual({
claude: { sandbox: { allowUnsandboxedCommands: true, excludedCommands: ['./gradlew'] } },
codex: { networkAccess: false },
});
});
it('すべて undefined なら undefined を返す', () => {
expect(mergeProviderOptions(undefined, undefined, undefined)).toBeUndefined();
});
});

View File

@ -139,14 +139,43 @@ describe('saveTaskFromInteractive', () => {
});
it('should record issue number in tasks.yaml when issue option is provided', async () => {
// Given: user declines worktree
mockConfirm.mockResolvedValueOnce(false);
// When
await saveTaskFromInteractive(testDir, 'Fix login bug', 'default', { issue: 42 });
// Then
const task = loadTasks(testDir).tasks[0]!;
expect(task.issue).toBe(42);
});
describe('with confirmAtEndMessage', () => {
it('should not save task when user declines confirmAtEndMessage', async () => {
mockConfirm.mockResolvedValueOnce(false);
await saveTaskFromInteractive(testDir, 'Task content', 'default', {
issue: 42,
confirmAtEndMessage: 'Add this issue to tasks?',
});
expect(fs.existsSync(path.join(testDir, '.takt', 'tasks.yaml'))).toBe(false);
});
it('should prompt worktree settings after confirming confirmAtEndMessage', async () => {
mockConfirm.mockResolvedValueOnce(true);
mockPromptInput.mockResolvedValueOnce('');
mockPromptInput.mockResolvedValueOnce('');
mockConfirm.mockResolvedValueOnce(true);
mockConfirm.mockResolvedValueOnce(false);
await saveTaskFromInteractive(testDir, 'Task content', 'default', {
issue: 42,
confirmAtEndMessage: 'Add this issue to tasks?',
});
expect(mockConfirm).toHaveBeenNthCalledWith(1, 'Add this issue to tasks?', true);
expect(mockConfirm).toHaveBeenNthCalledWith(2, 'Create worktree?', true);
const task = loadTasks(testDir).tasks[0]!;
expect(task.issue).toBe(42);
expect(task.worktree).toBe(true);
});
});
});

View File

@ -13,9 +13,6 @@ vi.mock('../infra/config/index.js', () => ({
listPieces: vi.fn(() => ['default']),
listPieceEntries: vi.fn(() => []),
isPiecePath: vi.fn(() => false),
loadAllPiecesWithSources: vi.fn(() => new Map()),
getPieceCategories: vi.fn(() => null),
buildCategorizedPieces: vi.fn(),
loadGlobalConfig: vi.fn(() => ({})),
}));
@ -60,29 +57,25 @@ vi.mock('../features/pieceSelection/index.js', () => ({
warnMissingPieces: vi.fn(),
selectPieceFromCategorizedPieces: vi.fn(),
selectPieceFromEntries: vi.fn(),
selectPiece: vi.fn(),
}));
import { confirm } from '../shared/prompt/index.js';
import {
getCurrentPiece,
loadAllPiecesWithSources,
getPieceCategories,
buildCategorizedPieces,
listPieces,
} from '../infra/config/index.js';
import { createSharedClone, autoCommitAndPush, summarizeTaskName } from '../infra/task/index.js';
import { warnMissingPieces, selectPieceFromCategorizedPieces } from '../features/pieceSelection/index.js';
import { selectPiece } from '../features/pieceSelection/index.js';
import { selectAndExecuteTask, determinePiece } from '../features/tasks/execute/selectAndExecute.js';
const mockConfirm = vi.mocked(confirm);
const mockGetCurrentPiece = vi.mocked(getCurrentPiece);
const mockLoadAllPiecesWithSources = vi.mocked(loadAllPiecesWithSources);
const mockGetPieceCategories = vi.mocked(getPieceCategories);
const mockBuildCategorizedPieces = vi.mocked(buildCategorizedPieces);
const mockListPieces = vi.mocked(listPieces);
const mockCreateSharedClone = vi.mocked(createSharedClone);
const mockAutoCommitAndPush = vi.mocked(autoCommitAndPush);
const mockSummarizeTaskName = vi.mocked(summarizeTaskName);
const mockWarnMissingPieces = vi.mocked(warnMissingPieces);
const mockSelectPieceFromCategorizedPieces = vi.mocked(selectPieceFromCategorizedPieces);
const mockSelectPiece = vi.mocked(selectPiece);
beforeEach(() => {
vi.clearAllMocks();
@ -121,44 +114,12 @@ describe('resolveAutoPr default in selectAndExecuteTask', () => {
expect(autoPrCall![1]).toBe(true);
});
it('should warn only user-origin missing pieces during interactive selection', async () => {
// Given: category selection is enabled and both builtin/user missing pieces exist
mockGetCurrentPiece.mockReturnValue('default');
mockLoadAllPiecesWithSources.mockReturnValue(new Map([
['default', {
source: 'builtin',
config: {
name: 'default',
movements: [],
initialMovement: 'start',
maxMovements: 1,
},
}],
]));
mockGetPieceCategories.mockReturnValue({
pieceCategories: [],
builtinPieceCategories: [],
userPieceCategories: [],
showOthersCategory: true,
othersCategoryName: 'Others',
});
mockBuildCategorizedPieces.mockReturnValue({
categories: [],
allPieces: new Map(),
missingPieces: [
{ categoryPath: ['Quick Start'], pieceName: 'default', source: 'builtin' },
{ categoryPath: ['Custom'], pieceName: 'my-missing', source: 'user' },
],
});
mockSelectPieceFromCategorizedPieces.mockResolvedValue('default');
it('should call selectPiece when no override is provided', async () => {
mockSelectPiece.mockResolvedValue('selected-piece');
// When
const selected = await determinePiece('/project');
// Then
expect(selected).toBe('default');
expect(mockWarnMissingPieces).toHaveBeenCalledWith([
{ categoryPath: ['Custom'], pieceName: 'my-missing', source: 'user' },
]);
expect(selected).toBe('selected-piece');
expect(mockSelectPiece).toHaveBeenCalledWith('/project');
});
});

View File

@ -5,19 +5,13 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('../infra/config/index.js', () => ({
listPieceEntries: vi.fn(() => []),
loadAllPiecesWithSources: vi.fn(() => new Map()),
getPieceCategories: vi.fn(() => null),
buildCategorizedPieces: vi.fn(),
loadPiece: vi.fn(() => null),
getCurrentPiece: vi.fn(() => 'default'),
setCurrentPiece: vi.fn(),
}));
vi.mock('../features/pieceSelection/index.js', () => ({
warnMissingPieces: vi.fn(),
selectPieceFromCategorizedPieces: vi.fn(),
selectPieceFromEntries: vi.fn(),
selectPiece: vi.fn(),
}));
vi.mock('../shared/ui/index.js', () => ({
@ -26,65 +20,41 @@ vi.mock('../shared/ui/index.js', () => ({
error: vi.fn(),
}));
import {
loadAllPiecesWithSources,
getPieceCategories,
buildCategorizedPieces,
} from '../infra/config/index.js';
import {
warnMissingPieces,
selectPieceFromCategorizedPieces,
} from '../features/pieceSelection/index.js';
import { getCurrentPiece, loadPiece, setCurrentPiece } from '../infra/config/index.js';
import { selectPiece } from '../features/pieceSelection/index.js';
import { switchPiece } from '../features/config/switchPiece.js';
const mockLoadAllPiecesWithSources = vi.mocked(loadAllPiecesWithSources);
const mockGetPieceCategories = vi.mocked(getPieceCategories);
const mockBuildCategorizedPieces = vi.mocked(buildCategorizedPieces);
const mockWarnMissingPieces = vi.mocked(warnMissingPieces);
const mockSelectPieceFromCategorizedPieces = vi.mocked(selectPieceFromCategorizedPieces);
const mockGetCurrentPiece = vi.mocked(getCurrentPiece);
const mockLoadPiece = vi.mocked(loadPiece);
const mockSetCurrentPiece = vi.mocked(setCurrentPiece);
const mockSelectPiece = vi.mocked(selectPiece);
describe('switchPiece', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should warn only user-origin missing pieces during interactive switch', async () => {
// Given
mockLoadAllPiecesWithSources.mockReturnValue(new Map([
['default', {
source: 'builtin',
config: {
name: 'default',
it('should call selectPiece with fallbackToDefault: false', async () => {
mockSelectPiece.mockResolvedValue(null);
const switched = await switchPiece('/project');
expect(switched).toBe(false);
expect(mockSelectPiece).toHaveBeenCalledWith('/project', { fallbackToDefault: false });
});
it('should switch to selected piece', async () => {
mockSelectPiece.mockResolvedValue('new-piece');
mockLoadPiece.mockReturnValue({
name: 'new-piece',
movements: [],
initialMovement: 'start',
maxMovements: 1,
},
}],
]));
mockGetPieceCategories.mockReturnValue({
pieceCategories: [],
builtinPieceCategories: [],
userPieceCategories: [],
showOthersCategory: true,
othersCategoryName: 'Others',
});
mockBuildCategorizedPieces.mockReturnValue({
categories: [],
allPieces: new Map(),
missingPieces: [
{ categoryPath: ['Quick Start'], pieceName: 'default', source: 'builtin' },
{ categoryPath: ['Custom'], pieceName: 'my-missing', source: 'user' },
],
});
mockSelectPieceFromCategorizedPieces.mockResolvedValue(null);
// When
const switched = await switchPiece('/project');
// Then
expect(switched).toBe(false);
expect(mockWarnMissingPieces).toHaveBeenCalledWith([
{ categoryPath: ['Custom'], pieceName: 'my-missing', source: 'user' },
]);
expect(switched).toBe(true);
expect(mockSetCurrentPiece).toHaveBeenCalledWith('/project', 'new-piece');
});
});

View File

@ -5,8 +5,9 @@
import { existsSync, readFileSync } from 'node:fs';
import { basename, dirname } from 'node:path';
import { loadCustomAgents, loadAgentPrompt, loadGlobalConfig, loadProjectConfig } from '../infra/config/index.js';
import { mergeProviderOptions } from '../infra/config/loaders/pieceParser.js';
import { getProvider, type ProviderType, type ProviderCallOptions } from '../infra/providers/index.js';
import type { AgentResponse, CustomAgentConfig } from '../core/models/index.js';
import type { AgentResponse, CustomAgentConfig, MovementProviderOptions } from '../core/models/index.js';
import { createLogger } from '../shared/utils/index.js';
import { loadTemplate } from '../shared/prompts/index.js';
import type { RunAgentOptions } from './types.js';
@ -92,6 +93,24 @@ export class AgentRunner {
return `${dir}/${name}`;
}
/**
* Resolve provider options with 4-layer priority: Global < Local < Step (piece+movement merged).
* Step already contains the piece+movement merge result from pieceParser.
*/
private static resolveProviderOptions(
cwd: string,
stepOptions?: MovementProviderOptions,
): MovementProviderOptions | undefined {
let globalOptions: MovementProviderOptions | undefined;
try {
globalOptions = loadGlobalConfig().providerOptions;
} catch { /* ignore */ }
const localOptions = loadProjectConfig(cwd).provider_options;
return mergeProviderOptions(globalOptions, localOptions, stepOptions);
}
/** Build ProviderCallOptions from RunAgentOptions */
private static buildCallOptions(
resolvedProvider: ProviderType,
@ -107,7 +126,7 @@ export class AgentRunner {
maxTurns: options.maxTurns,
model: AgentRunner.resolveModel(resolvedProvider, options, agentConfig),
permissionMode: options.permissionMode,
providerOptions: options.providerOptions,
providerOptions: AgentRunner.resolveProviderOptions(options.cwd, options.providerOptions),
onStream: options.onStream,
onPermissionRequest: options.onPermissionRequest,
onAskUserQuestion: options.onAskUserQuestion,

View File

@ -3,7 +3,7 @@
*/
import type { StreamCallback, PermissionHandler, AskUserQuestionHandler } from '../infra/claude/types.js';
import type { PermissionMode, Language, McpServerConfig } from '../core/models/index.js';
import type { PermissionMode, Language, McpServerConfig, MovementProviderOptions } from '../core/models/index.js';
export type { StreamCallback };
@ -25,10 +25,7 @@ export interface RunAgentOptions {
/** Permission mode for tool execution (from piece step) */
permissionMode?: PermissionMode;
/** Provider-specific movement options */
providerOptions?: {
codex?: { networkAccess?: boolean };
opencode?: { networkAccess?: boolean };
};
providerOptions?: MovementProviderOptions;
onStream?: StreamCallback;
onPermissionRequest?: PermissionHandler;
onAskUserQuestion?: AskUserQuestionHandler;

View File

@ -22,6 +22,7 @@ import {
resolveLanguage,
type InteractiveModeResult,
} from '../../features/interactive/index.js';
import { dispatchConversationAction } from '../../features/interactive/actionDispatcher.js';
import { getPieceDescription, loadGlobalConfig } from '../../infra/config/index.js';
import { DEFAULT_PIECE_NAME } from '../../shared/constants.js';
import { program, resolvedCwd, pipelineMode } from './program.js';
@ -202,33 +203,27 @@ export async function executeDefaultAction(task?: string): Promise<void> {
}
}
switch (result.action) {
case 'execute':
await dispatchConversationAction(result, {
execute: async ({ task: confirmedTask }) => {
selectOptions.interactiveUserInput = true;
selectOptions.piece = pieceId;
selectOptions.interactiveMetadata = { confirmed: true, task: result.task };
await selectAndExecuteTask(resolvedCwd, result.task, selectOptions, agentOverrides);
break;
case 'create_issue':
{
const issueNumber = createIssueFromTask(result.task);
selectOptions.interactiveMetadata = { confirmed: true, task: confirmedTask };
await selectAndExecuteTask(resolvedCwd, confirmedTask, selectOptions, agentOverrides);
},
create_issue: async ({ task: confirmedTask }) => {
const issueNumber = createIssueFromTask(confirmedTask);
if (issueNumber !== undefined) {
await saveTaskFromInteractive(resolvedCwd, result.task, pieceId, {
await saveTaskFromInteractive(resolvedCwd, confirmedTask, pieceId, {
issue: issueNumber,
confirmAtEndMessage: 'Add this issue to tasks?',
});
}
}
break;
case 'save_task':
await saveTaskFromInteractive(resolvedCwd, result.task, pieceId);
break;
case 'cancel':
break;
}
},
save_task: async ({ task: confirmedTask }) => {
await saveTaskFromInteractive(resolvedCwd, confirmedTask, pieceId);
},
cancel: () => undefined,
});
}
program

View File

@ -2,6 +2,8 @@
* Configuration types (global and project)
*/
import type { MovementProviderOptions } from './piece-types.js';
/** Custom agent configuration */
export interface CustomAgentConfig {
name: string;
@ -86,6 +88,8 @@ export interface GlobalConfig {
pieceCategoriesFile?: string;
/** Per-persona provider overrides (e.g., { coder: 'codex' }) */
personaProviders?: Record<string, 'claude' | 'codex' | 'opencode' | 'mock'>;
/** Global provider-specific options (lowest priority) */
providerOptions?: MovementProviderOptions;
/** Branch name generation strategy: 'romaji' (fast, default) or 'ai' (slow) */
branchNameStrategy?: 'romaji' | 'ai';
/** Prevent macOS idle sleep during takt execution using caffeinate (default: false) */
@ -107,4 +111,5 @@ export interface ProjectConfig {
piece?: string;
agents?: CustomAgentConfig[];
provider?: 'claude' | 'codex' | 'opencode' | 'mock';
providerOptions?: MovementProviderOptions;
}

View File

@ -14,6 +14,7 @@ export type {
PartResult,
TeamLeaderConfig,
PieceRule,
MovementProviderOptions,
PieceMovement,
ArpeggioMovementConfig,
ArpeggioMergeMovementConfig,

View File

@ -92,10 +92,24 @@ export interface OpenCodeProviderOptions {
networkAccess?: boolean;
}
/** Claude sandbox settings (maps to SDK SandboxSettings) */
export interface ClaudeSandboxSettings {
/** Allow all Bash commands to run outside the sandbox */
allowUnsandboxedCommands?: boolean;
/** Specific commands to exclude from sandbox (e.g., ["./gradlew", "npm test"]) */
excludedCommands?: string[];
}
/** Claude provider-specific options */
export interface ClaudeProviderOptions {
sandbox?: ClaudeSandboxSettings;
}
/** Provider-specific movement options */
export interface MovementProviderOptions {
codex?: CodexProviderOptions;
opencode?: OpenCodeProviderOptions;
claude?: ClaudeProviderOptions;
}
/** Single movement in a piece */

View File

@ -59,6 +59,12 @@ export const StatusSchema = z.enum([
/** Permission mode schema for tool execution */
export const PermissionModeSchema = z.enum(['readonly', 'edit', 'full']);
/** Claude sandbox settings schema */
export const ClaudeSandboxSchema = z.object({
allow_unsandboxed_commands: z.boolean().optional(),
excluded_commands: z.array(z.string()).optional(),
}).optional();
/** Provider-specific movement options schema */
export const MovementProviderOptionsSchema = z.object({
codex: z.object({
@ -67,6 +73,9 @@ export const MovementProviderOptionsSchema = z.object({
opencode: z.object({
network_access: z.boolean().optional(),
}).optional(),
claude: z.object({
sandbox: ClaudeSandboxSchema,
}).optional(),
}).optional();
/** Piece-level provider options schema */
@ -414,6 +423,8 @@ export const GlobalConfigSchema = z.object({
piece_categories_file: z.string().optional(),
/** Per-persona provider overrides (e.g., { coder: 'codex' }) */
persona_providers: z.record(z.string(), z.enum(['claude', 'codex', 'opencode', 'mock'])).optional(),
/** Global provider-specific options (lowest priority) */
provider_options: MovementProviderOptionsSchema,
/** Branch name generation strategy: 'romaji' (fast, default) or 'ai' (slow) */
branch_name_strategy: z.enum(['romaji', 'ai']).optional(),
/** Prevent macOS idle sleep during takt execution using caffeinate (default: false) */
@ -441,4 +452,5 @@ export const ProjectConfigSchema = z.object({
piece: z.string().optional(),
agents: z.array(CustomAgentConfigSchema).optional(),
provider: z.enum(['claude', 'codex', 'opencode', 'mock']).optional(),
provider_options: MovementProviderOptionsSchema,
});

View File

@ -37,6 +37,7 @@ export type {
OutputContractItem,
OutputContractEntry,
McpServerConfig,
MovementProviderOptions,
PieceMovement,
ArpeggioMovementConfig,
ArpeggioMergeMovementConfig,

View File

@ -3,48 +3,23 @@
*/
import {
listPieceEntries,
loadAllPiecesWithSources,
getPieceCategories,
buildCategorizedPieces,
loadPiece,
getCurrentPiece,
setCurrentPiece,
} from '../../infra/config/index.js';
import { info, success, error } from '../../shared/ui/index.js';
import {
warnMissingPieces,
selectPieceFromCategorizedPieces,
selectPieceFromEntries,
} from '../pieceSelection/index.js';
import { selectPiece } from '../pieceSelection/index.js';
/**
* Switch to a different piece
* @returns true if switch was successful
*/
export async function switchPiece(cwd: string, pieceName?: string): Promise<boolean> {
// No piece specified - show selection prompt
if (!pieceName) {
const current = getCurrentPiece(cwd);
info(`Current piece: ${current}`);
const categoryConfig = getPieceCategories();
let selected: string | null;
if (categoryConfig) {
const allPieces = loadAllPiecesWithSources(cwd);
if (allPieces.size === 0) {
info('No pieces found.');
selected = null;
} else {
const categorized = buildCategorizedPieces(allPieces, categoryConfig);
warnMissingPieces(categorized.missingPieces.filter((missing) => missing.source === 'user'));
selected = await selectPieceFromCategorizedPieces(categorized, current);
}
} else {
const entries = listPieceEntries(cwd);
selected = await selectPieceFromEntries(entries, current);
}
const selected = await selectPiece(cwd, { fallbackToDefault: false });
if (!selected) {
info('Cancelled');
return false;

View File

@ -0,0 +1,20 @@
/**
* Shared dispatcher for post-conversation actions.
*/
export interface ConversationActionResult<A extends string> {
action: A;
task: string;
}
export type ConversationActionHandler<A extends string, R> = (
result: ConversationActionResult<A>,
) => Promise<R> | R;
export async function dispatchConversationAction<A extends string, R>(
result: ConversationActionResult<A>,
handlers: Record<A, ConversationActionHandler<A, R>>,
): Promise<R> {
return handlers[result.action](result);
}

View File

@ -28,6 +28,7 @@ import {
type InteractiveModeResult,
type InteractiveUIText,
type ConversationMessage,
type PostSummaryAction,
resolveLanguage,
buildSummaryPrompt,
selectPostSummaryAction,
@ -171,6 +172,8 @@ export async function callAIWithRetry(
}
}
export type { PostSummaryAction } from './interactive.js';
/** Strategy for customizing conversation loop behavior */
export interface ConversationStrategy {
/** System prompt for AI calls */
@ -181,6 +184,8 @@ export interface ConversationStrategy {
transformPrompt: (userMessage: string) => string;
/** Intro message displayed at start */
introMessage: string;
/** Custom action selector (optional). If not provided, uses default selectPostSummaryAction. */
selectAction?: (task: string, lang: 'en' | 'ja') => Promise<PostSummaryAction | null>;
}
/**
@ -284,7 +289,9 @@ export async function runConversationLoop(
return { action: 'cancel', task: '' };
}
const task = summaryResult.content.trim();
const selectedAction = await selectPostSummaryAction(task, ui.proposed, ui);
const selectedAction = strategy.selectAction
? await strategy.selectAction(task, ctx.lang)
: await selectPostSummaryAction(task, ui.proposed, ui);
if (selectedAction === 'continue' || selectedAction === null) {
info(ui.continuePrompt);
continue;

View File

@ -169,21 +169,90 @@ export function buildSummaryPrompt(
export type PostSummaryAction = InteractiveModeAction | 'continue';
export async function selectPostSummaryAction(
export type SummaryActionValue = 'execute' | 'create_issue' | 'save_task' | 'continue';
export interface SummaryActionOption {
label: string;
value: SummaryActionValue;
}
export type SummaryActionLabels = {
execute: string;
createIssue?: string;
saveTask: string;
continue: string;
};
export const BASE_SUMMARY_ACTIONS: readonly SummaryActionValue[] = [
'execute',
'save_task',
'continue',
];
export function buildSummaryActionOptions(
labels: SummaryActionLabels,
append: readonly SummaryActionValue[] = [],
): SummaryActionOption[] {
const order = [...BASE_SUMMARY_ACTIONS, ...append];
const seen = new Set<SummaryActionValue>();
const options: SummaryActionOption[] = [];
for (const action of order) {
if (seen.has(action)) continue;
seen.add(action);
if (action === 'execute') {
options.push({ label: labels.execute, value: action });
continue;
}
if (action === 'create_issue') {
if (labels.createIssue) {
options.push({ label: labels.createIssue, value: action });
}
continue;
}
if (action === 'save_task') {
options.push({ label: labels.saveTask, value: action });
continue;
}
options.push({ label: labels.continue, value: action });
}
return options;
}
export async function selectSummaryAction(
task: string,
proposedLabel: string,
ui: InteractiveUIText,
actionPrompt: string,
options: SummaryActionOption[],
): Promise<PostSummaryAction | null> {
blankLine();
info(proposedLabel);
console.log(task);
return selectOption<PostSummaryAction>(ui.actionPrompt, [
{ label: ui.actions.execute, value: 'execute' },
{ label: ui.actions.createIssue, value: 'create_issue' },
{ label: ui.actions.saveTask, value: 'save_task' },
{ label: ui.actions.continue, value: 'continue' },
]);
return selectOption<PostSummaryAction>(actionPrompt, options);
}
export async function selectPostSummaryAction(
task: string,
proposedLabel: string,
ui: InteractiveUIText,
): Promise<PostSummaryAction | null> {
return selectSummaryAction(
task,
proposedLabel,
ui.actionPrompt,
buildSummaryActionOptions(
{
execute: ui.actions.execute,
createIssue: ui.actions.createIssue,
saveTask: ui.actions.saveTask,
continue: ui.actions.continue,
},
['create_issue'],
),
);
}
export type InteractiveModeAction = 'execute' | 'save_task' | 'create_issue' | 'cancel';

View File

@ -12,11 +12,18 @@ import {
} from '../../infra/config/global/index.js';
import {
findPieceCategories,
listPieces,
listPieceEntries,
loadAllPiecesWithSources,
getPieceCategories,
buildCategorizedPieces,
getCurrentPiece,
type PieceDirEntry,
type PieceCategoryNode,
type CategorizedPieces,
type MissingPiece,
} from '../../infra/config/index.js';
import { DEFAULT_PIECE_NAME } from '../../shared/constants.js';
/** Top-level selection item: either a piece or a category containing pieces */
export type PieceSelectionItem =
@ -504,3 +511,44 @@ export async function selectPieceFromEntries(
const entriesToUse = customEntries.length > 0 ? customEntries : builtinEntries;
return selectPieceFromEntriesWithCategories(entriesToUse, currentPiece);
}
export interface SelectPieceOptions {
fallbackToDefault?: boolean;
}
export async function selectPiece(
cwd: string,
options?: SelectPieceOptions,
): Promise<string | null> {
const fallbackToDefault = options?.fallbackToDefault !== false;
const categoryConfig = getPieceCategories();
const currentPiece = getCurrentPiece(cwd);
if (categoryConfig) {
const allPieces = loadAllPiecesWithSources(cwd);
if (allPieces.size === 0) {
if (fallbackToDefault) {
info(`No pieces found. Using default: ${DEFAULT_PIECE_NAME}`);
return DEFAULT_PIECE_NAME;
}
info('No pieces found.');
return null;
}
const categorized = buildCategorizedPieces(allPieces, categoryConfig);
warnMissingPieces(categorized.missingPieces.filter((missing) => missing.source === 'user'));
return selectPieceFromCategorizedPieces(categorized, currentPiece);
}
const availablePieces = listPieces(cwd);
if (availablePieces.length === 0) {
if (fallbackToDefault) {
info(`No pieces found. Using default: ${DEFAULT_PIECE_NAME}`);
return DEFAULT_PIECE_NAME;
}
info('No pieces found.');
return null;
}
const entries = listPieceEntries(cwd);
return selectPieceFromEntries(entries, currentPiece);
}

View File

@ -151,13 +151,13 @@ export async function saveTaskFromInteractive(
piece?: string,
options?: { issue?: number; confirmAtEndMessage?: string },
): Promise<void> {
const settings = await promptWorktreeSettings();
if (options?.confirmAtEndMessage) {
const approved = await confirm(options.confirmAtEndMessage, true);
if (!approved) {
return;
}
}
const settings = await promptWorktreeSettings();
const created = await saveTaskFile(cwd, task, { piece, issue: options?.issue, ...settings });
displayTaskCreationResult(created, settings, piece);
}

View File

@ -7,13 +7,8 @@
*/
import {
getCurrentPiece,
listPieces,
listPieceEntries,
isPiecePath,
loadAllPiecesWithSources,
getPieceCategories,
buildCategorizedPieces,
loadGlobalConfig,
} from '../../../infra/config/index.js';
import { confirm } from '../../../shared/prompt/index.js';
@ -24,63 +19,12 @@ import { createLogger } from '../../../shared/utils/index.js';
import { createPullRequest, buildPrBody, pushBranch } from '../../../infra/github/index.js';
import { executeTask } from './taskExecution.js';
import type { TaskExecutionOptions, WorktreeConfirmationResult, SelectAndExecuteOptions } from './types.js';
import {
warnMissingPieces,
selectPieceFromCategorizedPieces,
selectPieceFromEntries,
} from '../../pieceSelection/index.js';
import { selectPiece } from '../../pieceSelection/index.js';
export type { WorktreeConfirmationResult, SelectAndExecuteOptions };
const log = createLogger('selectAndExecute');
/**
* Select a piece interactively with directory categories and bookmarks.
*/
async function selectPieceWithDirectoryCategories(cwd: string): Promise<string | null> {
const availablePieces = listPieces(cwd);
const currentPiece = getCurrentPiece(cwd);
if (availablePieces.length === 0) {
info(`No pieces found. Using default: ${DEFAULT_PIECE_NAME}`);
return DEFAULT_PIECE_NAME;
}
if (availablePieces.length === 1 && availablePieces[0]) {
return availablePieces[0];
}
const entries = listPieceEntries(cwd);
return selectPieceFromEntries(entries, currentPiece);
}
/**
* Select a piece interactively with 2-stage category support.
*/
async function selectPiece(cwd: string): Promise<string | null> {
const categoryConfig = getPieceCategories();
if (categoryConfig) {
const current = getCurrentPiece(cwd);
const allPieces = loadAllPiecesWithSources(cwd);
if (allPieces.size === 0) {
info(`No pieces found. Using default: ${DEFAULT_PIECE_NAME}`);
return DEFAULT_PIECE_NAME;
}
const categorized = buildCategorizedPieces(allPieces, categoryConfig);
warnMissingPieces(categorized.missingPieces.filter((missing) => missing.source === 'user'));
return selectPieceFromCategorizedPieces(categorized, current);
}
return selectPieceWithDirectoryCategories(cwd);
}
/**
* Determine piece to use.
*
* - If override looks like a path (isPiecePath), return it directly (validation is done at load time).
* - If override is a name, validate it exists in available pieces.
* - If no override, prompt user to select interactively.
*/
export async function determinePiece(cwd: string, override?: string): Promise<string | null> {
if (override) {
if (isPiecePath(override)) {

View File

@ -44,6 +44,12 @@ export {
instructBranch,
} from './taskActions.js';
export {
type InstructModeAction,
type InstructModeResult,
runInstructMode,
} from './instructMode.js';
/** Task action type for pending task action selection menu */
type PendingTaskAction = 'delete';

View File

@ -0,0 +1,123 @@
/**
* Instruct mode for branch-based tasks.
*
* Provides conversation loop for additional instructions on existing branches,
* similar to interactive mode but with branch context and limited actions.
*/
import {
initializeSession,
displayAndClearSessionState,
runConversationLoop,
type SessionContext,
type ConversationStrategy,
type PostSummaryAction,
} from '../../interactive/conversationLoop.js';
import {
resolveLanguage,
buildSummaryActionOptions,
selectSummaryAction,
} from '../../interactive/interactive.js';
import { loadTemplate } from '../../../shared/prompts/index.js';
import { getLabelObject } from '../../../shared/i18n/index.js';
import { loadGlobalConfig } from '../../../infra/config/index.js';
export type InstructModeAction = 'execute' | 'save_task' | 'cancel';
export interface InstructModeResult {
action: InstructModeAction;
task: string;
}
export interface InstructUIText {
intro: string;
resume: string;
noConversation: string;
summarizeFailed: string;
continuePrompt: string;
proposed: string;
actionPrompt: string;
actions: {
execute: string;
saveTask: string;
continue: string;
};
cancelled: string;
}
const INSTRUCT_TOOLS = ['Read', 'Glob', 'Grep', 'Bash', 'WebSearch', 'WebFetch'];
function createSelectInstructAction(ui: InstructUIText): (task: string, lang: 'en' | 'ja') => Promise<PostSummaryAction | null> {
return async (task: string, _lang: 'en' | 'ja'): Promise<PostSummaryAction | null> => {
return selectSummaryAction(
task,
ui.proposed,
ui.actionPrompt,
buildSummaryActionOptions({
execute: ui.actions.execute,
saveTask: ui.actions.saveTask,
continue: ui.actions.continue,
}),
);
};
}
export async function runInstructMode(
cwd: string,
branchContext: string,
branchName: string,
): Promise<InstructModeResult> {
const globalConfig = loadGlobalConfig();
const lang = resolveLanguage(globalConfig.language);
if (!globalConfig.provider) {
throw new Error('Provider is not configured.');
}
const baseCtx = initializeSession(cwd, 'instruct');
const ctx: SessionContext = { ...baseCtx, lang, personaName: 'instruct' };
displayAndClearSessionState(cwd, ctx.lang);
const ui = getLabelObject<InstructUIText>('instruct.ui', ctx.lang);
const systemPrompt = loadTemplate('score_interactive_system_prompt', ctx.lang, {
hasPiecePreview: false,
pieceStructure: '',
movementDetails: '',
});
const branchIntro = ctx.lang === 'ja'
? `## ブランチ: ${branchName}\n\n${branchContext}`
: `## Branch: ${branchName}\n\n${branchContext}`;
const introMessage = `${branchIntro}\n\n${ui.intro}`;
const policyContent = loadTemplate('score_interactive_policy', ctx.lang, {});
function injectPolicy(userMessage: string): string {
const policyIntro = ctx.lang === 'ja'
? '以下のポリシーは行動規範です。必ず遵守してください。'
: 'The following policy defines behavioral guidelines. Please follow them.';
const reminderLabel = ctx.lang === 'ja'
? '上記の Policy セクションで定義されたポリシー規範を遵守してください。'
: 'Please follow the policy guidelines defined in the Policy section above.';
return `## Policy\n${policyIntro}\n\n${policyContent}\n\n---\n\n${userMessage}\n\n---\n**Policy Reminder:** ${reminderLabel}`;
}
const strategy: ConversationStrategy = {
systemPrompt,
allowedTools: INSTRUCT_TOOLS,
transformPrompt: injectPolicy,
introMessage,
selectAction: createSelectInstructAction(ui),
};
const result = await runConversationLoop(cwd, ctx, strategy, undefined, undefined);
if (result.action === 'cancel') {
return { action: 'cancel', task: '' };
}
return { action: result.action as InstructModeAction, task: result.task };
}

View File

@ -19,18 +19,19 @@ import {
autoCommitAndPush,
type BranchListItem,
} from '../../../infra/task/index.js';
import { selectOption, promptInput } from '../../../shared/prompt/index.js';
import { selectOption } from '../../../shared/prompt/index.js';
import { info, success, error as logError, warn, header, blankLine } from '../../../shared/ui/index.js';
import { createLogger, getErrorMessage } from '../../../shared/utils/index.js';
import { executeTask } from '../execute/taskExecution.js';
import type { TaskExecutionOptions } from '../execute/types.js';
import { listPieces, getCurrentPiece } from '../../../infra/config/index.js';
import { DEFAULT_PIECE_NAME } from '../../../shared/constants.js';
import { encodeWorktreePath } from '../../../infra/config/project/sessionStore.js';
import { runInstructMode } from './instructMode.js';
import { saveTaskFile } from '../add/index.js';
import { selectPiece } from '../../pieceSelection/index.js';
import { dispatchConversationAction } from '../../interactive/actionDispatcher.js';
const log = createLogger('list-tasks');
/** Actions available for a listed branch */
export type ListAction = 'diff' | 'instruct' | 'try' | 'merge' | 'delete';
/**
@ -254,29 +255,6 @@ export function deleteBranch(projectDir: string, item: BranchListItem): boolean
}
}
/**
* Get the piece to use for instruction.
*/
async function selectPieceForInstruction(projectDir: string): Promise<string | null> {
const availablePieces = listPieces(projectDir);
const currentPiece = getCurrentPiece(projectDir);
if (availablePieces.length === 0) {
return DEFAULT_PIECE_NAME;
}
if (availablePieces.length === 1 && availablePieces[0]) {
return availablePieces[0];
}
const options = availablePieces.map((name) => ({
label: name === currentPiece ? `${name} (current)` : name,
value: name,
}));
return await selectOption('Select piece:', options);
}
/**
* Get branch context: diff stat and commit log from main branch.
*/
@ -327,8 +305,8 @@ function getBranchContext(projectDir: string, branch: string): string {
}
/**
* Instruct branch: create a temp clone, give additional instructions,
* auto-commit+push, then remove clone.
* Instruct branch: create a temp clone, give additional instructions via
* interactive conversation, then auto-commit+push or save as task file.
*/
export async function instructBranch(
projectDir: string,
@ -337,33 +315,58 @@ export async function instructBranch(
): Promise<boolean> {
const { branch } = item.info;
const instruction = await promptInput('Enter instruction');
if (!instruction) {
info('Cancelled');
return false;
}
const branchContext = getBranchContext(projectDir, branch);
const result = await runInstructMode(projectDir, branchContext, branch);
let selectedPiece: string | null = null;
const selectedPiece = await selectPieceForInstruction(projectDir);
const ensurePieceSelected = async (): Promise<string | null> => {
if (selectedPiece) {
return selectedPiece;
}
selectedPiece = await selectPiece(projectDir);
if (!selectedPiece) {
info('Cancelled');
return null;
}
return selectedPiece;
};
return dispatchConversationAction(result, {
cancel: () => {
info('Cancelled');
return false;
},
save_task: async ({ task }) => {
const piece = await ensurePieceSelected();
if (!piece) {
return false;
}
const created = await saveTaskFile(projectDir, task, { piece });
success(`Task saved: ${created.taskName}`);
info(` File: ${created.tasksFile}`);
log.info('Task saved from instruct mode', { branch, piece });
return true;
},
execute: async ({ task }) => {
const piece = await ensurePieceSelected();
if (!piece) {
return false;
}
log.info('Instructing branch via temp clone', { branch, piece: selectedPiece });
log.info('Instructing branch via temp clone', { branch, piece });
info(`Running instruction on ${branch}...`);
const clone = createTempCloneForBranch(projectDir, branch);
try {
const branchContext = getBranchContext(projectDir, branch);
const fullInstruction = branchContext
? `${branchContext}## 追加指示\n${instruction}`
: instruction;
? `${branchContext}## 追加指示\n${task}`
: task;
const taskSuccess = await executeTask({
task: fullInstruction,
cwd: clone.path,
pieceIdentifier: selectedPiece,
pieceIdentifier: piece,
projectCwd: projectDir,
agentOverrides: options,
});
@ -387,4 +390,6 @@ export async function instructBranch(
removeClone(clone.path);
removeCloneMeta(projectDir, branch);
}
},
});
}

View File

@ -52,6 +52,7 @@ export class ClaudeClient {
bypassPermissions: options.bypassPermissions,
anthropicApiKey: options.anthropicApiKey,
outputSchema: options.outputSchema,
sandbox: options.sandbox,
};
}

View File

@ -95,6 +95,10 @@ export class SdkOptionsBuilder {
sdkOptions.stderr = this.options.onStderr;
}
if (this.options.sandbox) {
sdkOptions.sandbox = this.options.sandbox;
}
return sdkOptions;
}

View File

@ -5,8 +5,10 @@
* used throughout the Claude integration layer.
*/
import type { PermissionUpdate, AgentDefinition } from '@anthropic-ai/claude-agent-sdk';
import type { PermissionUpdate, AgentDefinition, SandboxSettings } from '@anthropic-ai/claude-agent-sdk';
import type { PermissionMode, McpServerConfig } from '../../core/models/index.js';
export type { SandboxSettings };
import type { PermissionResult } from '../../core/piece/index.js';
// Re-export PermissionResult for convenience
@ -145,6 +147,8 @@ export interface ClaudeCallOptions {
anthropicApiKey?: string;
/** JSON Schema for structured output */
outputSchema?: Record<string, unknown>;
/** Sandbox settings for Claude SDK */
sandbox?: SandboxSettings;
}
/** Options for spawning a Claude SDK query (low-level, used by executor/process) */
@ -176,4 +180,6 @@ export interface ClaudeSpawnOptions {
outputSchema?: Record<string, unknown>;
/** Callback for stderr output from the Claude Code process */
onStderr?: (data: string) => void;
/** Sandbox settings for Claude SDK */
sandbox?: SandboxSettings;
}

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 { normalizeProviderOptions } from '../loaders/pieceParser.js';
import { getGlobalConfigPath, getProjectConfigPath } from '../paths.js';
import { DEFAULT_LANGUAGE } from '../../../shared/constants.js';
import { parseProviderModel } from '../../../shared/utils/providerModel.js';
@ -124,6 +125,7 @@ export class GlobalConfigManager {
bookmarksFile: parsed.bookmarks_file,
pieceCategoriesFile: parsed.piece_categories_file,
personaProviders: parsed.persona_providers,
providerOptions: normalizeProviderOptions(parsed.provider_options),
branchNameStrategy: parsed.branch_name_strategy,
preventSleep: parsed.prevent_sleep,
notificationSound: parsed.notification_sound,

View File

@ -24,34 +24,61 @@ import {
type RawStep = z.output<typeof PieceMovementRawSchema>;
function normalizeProviderOptions(
import type { MovementProviderOptions } from '../../../core/models/piece-types.js';
/** Convert raw YAML provider_options (snake_case) to internal format (camelCase). */
export function normalizeProviderOptions(
raw: RawStep['provider_options'],
): PieceMovement['providerOptions'] {
): MovementProviderOptions | undefined {
if (!raw) return undefined;
const codex = raw.codex?.network_access === undefined
? undefined
: { networkAccess: raw.codex.network_access };
const opencode = raw.opencode?.network_access === undefined
? undefined
: { networkAccess: raw.opencode.network_access };
if (!codex && !opencode) return undefined;
return { ...(codex ? { codex } : {}), ...(opencode ? { opencode } : {}) };
const result: MovementProviderOptions = {};
if (raw.codex?.network_access !== undefined) {
result.codex = { networkAccess: raw.codex.network_access };
}
if (raw.opencode?.network_access !== undefined) {
result.opencode = { networkAccess: raw.opencode.network_access };
}
if (raw.claude?.sandbox) {
result.claude = {
sandbox: {
...(raw.claude.sandbox.allow_unsandboxed_commands !== undefined
? { allowUnsandboxedCommands: raw.claude.sandbox.allow_unsandboxed_commands }
: {}),
...(raw.claude.sandbox.excluded_commands !== undefined
? { excludedCommands: raw.claude.sandbox.excluded_commands }
: {}),
},
};
}
return Object.keys(result).length > 0 ? result : undefined;
}
function mergeProviderOptions(
base: PieceMovement['providerOptions'],
override: PieceMovement['providerOptions'],
): PieceMovement['providerOptions'] {
const codexNetworkAccess = override?.codex?.networkAccess ?? base?.codex?.networkAccess;
const opencodeNetworkAccess = override?.opencode?.networkAccess ?? base?.opencode?.networkAccess;
/**
* Deep merge provider options. Later sources override earlier ones.
* Exported for reuse in runner.ts (4-layer resolution).
*/
export function mergeProviderOptions(
...layers: (MovementProviderOptions | undefined)[]
): MovementProviderOptions | undefined {
const result: MovementProviderOptions = {};
const codex = codexNetworkAccess === undefined ? undefined : { networkAccess: codexNetworkAccess };
const opencode = opencodeNetworkAccess === undefined ? undefined : { networkAccess: opencodeNetworkAccess };
for (const layer of layers) {
if (!layer) continue;
if (layer.codex) {
result.codex = { ...result.codex, ...layer.codex };
}
if (layer.opencode) {
result.opencode = { ...result.opencode, ...layer.opencode };
}
if (layer.claude?.sandbox) {
result.claude = {
sandbox: { ...result.claude?.sandbox, ...layer.claude.sandbox },
};
}
}
if (!codex && !opencode) return undefined;
return { ...(codex ? { codex } : {}), ...(opencode ? { opencode } : {}) };
return Object.keys(result).length > 0 ? result : undefined;
}
/** Check if a raw output contract item is the object form (has 'name' property). */

View File

@ -3,6 +3,7 @@
*/
import type { PieceCategoryConfigNode } from '../../core/models/schemas.js';
import type { MovementProviderOptions } from '../../core/models/piece-types.js';
/** Permission mode for the project
* - default: Uses Agent SDK's acceptEdits mode (auto-accepts file edits, minimal prompts)
@ -22,6 +23,8 @@ export interface ProjectLocalConfig {
permissionMode?: PermissionMode;
/** Verbose output mode */
verbose?: boolean;
/** Provider-specific options (overrides global, overridden by piece/movement) */
provider_options?: MovementProviderOptions;
/** Piece categories (name -> piece list) */
piece_categories?: Record<string, PieceCategoryConfigNode>;
/** Show uncategorized pieces under Others category */

View File

@ -9,6 +9,7 @@ import type { AgentResponse } from '../../core/models/index.js';
import type { AgentSetup, Provider, ProviderAgent, ProviderCallOptions } from './types.js';
function toClaudeOptions(options: ProviderCallOptions): ClaudeCallOptions {
const claudeSandbox = options.providerOptions?.claude?.sandbox;
return {
cwd: options.cwd,
abortSignal: options.abortSignal,
@ -24,6 +25,10 @@ function toClaudeOptions(options: ProviderCallOptions): ClaudeCallOptions {
bypassPermissions: options.bypassPermissions,
anthropicApiKey: options.anthropicApiKey ?? resolveAnthropicApiKey(),
outputSchema: options.outputSchema,
sandbox: claudeSandbox ? {
allowUnsandboxedCommands: claudeSandbox.allowUnsandboxedCommands,
excludedCommands: claudeSandbox.excludedCommands,
} : undefined,
};
}

View File

@ -3,7 +3,7 @@
*/
import type { StreamCallback, PermissionHandler, AskUserQuestionHandler } from '../claude/index.js';
import type { AgentResponse, PermissionMode, McpServerConfig } from '../../core/models/index.js';
import type { AgentResponse, PermissionMode, McpServerConfig, MovementProviderOptions } from '../../core/models/index.js';
/** Agent setup configuration — determines HOW the provider invokes the agent */
export interface AgentSetup {
@ -31,10 +31,7 @@ export interface ProviderCallOptions {
/** Permission mode for tool execution (from piece step) */
permissionMode?: PermissionMode;
/** Provider-specific movement options */
providerOptions?: {
codex?: { networkAccess?: boolean };
opencode?: { networkAccess?: boolean };
};
providerOptions?: MovementProviderOptions;
onStream?: StreamCallback;
onPermissionRequest?: PermissionHandler;
onAskUserQuestion?: AskUserQuestionHandler;

View File

@ -68,6 +68,22 @@ piece:
sigintTimeout: "Graceful shutdown timed out after {timeoutMs}ms"
sigintForce: "Ctrl+C: Force exit"
# ===== Instruct Mode UI (takt list -> instruct) =====
instruct:
ui:
intro: "Instruct mode - describe additional instructions. Commands: /go (summarize), /cancel (exit)"
resume: "Resuming previous session"
noConversation: "No conversation yet. Please describe your instructions first."
summarizeFailed: "Failed to summarize conversation. Please try again."
continuePrompt: "Okay, continue describing your instructions."
proposed: "Proposed additional instructions:"
actionPrompt: "What would you like to do?"
actions:
execute: "Execute now"
saveTask: "Save as Task"
continue: "Continue editing"
cancelled: "Cancelled"
run:
notifyComplete: "Run complete ({total} tasks)"
notifyAbort: "Run finished with errors ({failed})"

View File

@ -68,6 +68,22 @@ piece:
sigintTimeout: "graceful停止がタイムアウトしました ({timeoutMs}ms)"
sigintForce: "Ctrl+C: 強制終了します"
# ===== Instruct Mode UI (takt list -> instruct) =====
instruct:
ui:
intro: "指示モード - 追加指示を入力してください。コマンド: /go要約, /cancel終了"
resume: "前回のセッションを再開します"
noConversation: "まだ会話がありません。まず追加指示を入力してください。"
summarizeFailed: "会話の要約に失敗しました。再度お試しください。"
continuePrompt: "続けて追加指示を入力してください。"
proposed: "提案された追加指示:"
actionPrompt: "どうしますか?"
actions:
execute: "実行する"
saveTask: "タスクにつむ"
continue: "会話を続ける"
cancelled: "キャンセルしました"
run:
notifyComplete: "run完了 ({total} tasks)"
notifyAbort: "runはエラー終了 ({failed})"