Merge branch 'develop' of https://github.com/nrslib/takt into develop
This commit is contained in:
commit
fcabcd94e4
@ -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 Phase(Phase 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
2
OPENCODE_CONFIG_CONTENT
Normal 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"}
|
||||
@ -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)
|
||||
|
||||
@ -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:**
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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
|
||||
|
||||
|
||||
@ -35,3 +35,4 @@
|
||||
- 後方互換・Legacy 対応を勝手に追加する → 絶対禁止
|
||||
- リファクタリングで置き換えたコード・エクスポートを残す → 禁止(明示的に残すよう指示されない限り削除する)
|
||||
- 根本原因を修正した上で安全機構を迂回するワークアラウンドを重ねる → 禁止
|
||||
- タスク指示書にない既存機能の削除・構造変更を「ついでに」行う → 禁止(計画に含まれていても、指示書に根拠がない大規模削除は報告する)
|
||||
|
||||
@ -43,6 +43,23 @@
|
||||
| 非機能要件 | パフォーマンス、セキュリティ等は満たされているか |
|
||||
| スコープ | 要求以上のことをしていないか(スコープクリープ) |
|
||||
|
||||
### スコープクリープの検出(削除は最重要チェック)
|
||||
|
||||
ファイルの**削除**と既存機能の**除去**はスコープクリープの最も危険な形態。
|
||||
追加は元に戻せるが、削除されたフローの復元は困難。
|
||||
|
||||
**必須手順:**
|
||||
1. 変更差分から削除されたファイル(D)と削除されたクラス・メソッド・エンドポイントを列挙する
|
||||
2. 各削除がタスク指示書のどの項目に対応するかを照合する
|
||||
3. タスク指示書に根拠がない削除は REJECT する
|
||||
|
||||
**典型的なスコープクリープ:**
|
||||
- 「ステータス変更」タスクで Saga やエンドポイントが丸ごと削除されている
|
||||
- 「UI修正」タスクでバックエンドのドメインモデルが構造変更されている
|
||||
- 「表示変更」タスクでビジネスロジックのフローが書き換えられている
|
||||
|
||||
レビュアーが「設計判断として妥当」と承認していても、タスク指示書のスコープ外であれば REJECT する。
|
||||
|
||||
### リスク評価
|
||||
|
||||
| 影響度\発生確率 | 低 | 中 | 高 |
|
||||
|
||||
@ -64,8 +64,19 @@
|
||||
- 循環依存を作らない
|
||||
- 責務の分離(読み取りと書き込み、ビジネスロジックと IO)
|
||||
|
||||
### スコープ規律
|
||||
|
||||
タスク指示書に明記された作業のみを計画する。暗黙の「改善」を勝手に含めない。
|
||||
|
||||
**削除の判断基準:**
|
||||
- **今回の変更で新たに未使用になったコード** → 削除を計画してよい(例: リネームした旧変数)
|
||||
- **既存の機能・フロー・エンドポイント・Saga・イベント** → タスク指示書で明示的に指示されない限り削除しない
|
||||
|
||||
「ステータスを5つに変更する」は「enum値を書き換える」であり、「不要になったフローを丸ごと削除する」ではない。
|
||||
タスク指示書の文言を拡大解釈しない。書かれていることだけを計画する。
|
||||
|
||||
### 計画の原則
|
||||
|
||||
- 後方互換コードは計画に含めない(明示的な指示がない限り不要)
|
||||
- 使われていないものは削除する計画を立てる
|
||||
- 今回の変更で新たに未使用になったコードは削除する計画を立てる
|
||||
- TODO コメントで済ませる計画は立てない。今やるか、やらないか
|
||||
|
||||
@ -102,6 +102,21 @@
|
||||
|
||||
「機能的に無害」は免罪符ではない。修正コストがほぼゼロの指摘を「非ブロッキング」「次回タスク」に分類することは妥協である。レビュアーが発見し、数分以内に修正できる問題は今回のタスクで修正させる。
|
||||
|
||||
### スコープクリープの検出(削除は最重要チェック)
|
||||
|
||||
ファイルの**削除**と既存機能の**除去**はスコープクリープの最も危険な形態。
|
||||
追加は元に戻せるが、削除されたフローの復元は困難。
|
||||
|
||||
**必須手順:**
|
||||
1. 変更差分から削除されたファイル(D)と削除されたクラス・メソッド・エンドポイントを列挙する
|
||||
2. 各削除がタスク指示書のどの項目に対応するかを照合する
|
||||
3. タスク指示書に根拠がない削除は REJECT する
|
||||
|
||||
**典型的なスコープクリープ:**
|
||||
- 「ステータス変更」タスクで Saga やエンドポイントが丸ごと削除されている
|
||||
- 「UI修正」タスクでバックエンドのドメインモデルが構造変更されている
|
||||
- 「表示変更」タスクでビジネスロジックのフローが書き換えられている
|
||||
|
||||
### ピース全体の見直し
|
||||
|
||||
レポートディレクトリ内の全レポートを確認し、ピース全体の整合性をチェックする。
|
||||
|
||||
168
docs/provider-sandbox.md
Normal file
168
docs/provider-sandbox.md
Normal 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.
|
||||
@ -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
4
package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
39
src/__tests__/actionDispatcher.test.ts
Normal file
39
src/__tests__/actionDispatcher.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
|
||||
282
src/__tests__/instructMode.test.ts
Normal file
282
src/__tests__/instructMode.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@ -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');
|
||||
});
|
||||
});
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -14,6 +14,7 @@ export type {
|
||||
PartResult,
|
||||
TeamLeaderConfig,
|
||||
PieceRule,
|
||||
MovementProviderOptions,
|
||||
PieceMovement,
|
||||
ArpeggioMovementConfig,
|
||||
ArpeggioMergeMovementConfig,
|
||||
|
||||
@ -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 */
|
||||
|
||||
@ -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,
|
||||
});
|
||||
|
||||
@ -37,6 +37,7 @@ export type {
|
||||
OutputContractItem,
|
||||
OutputContractEntry,
|
||||
McpServerConfig,
|
||||
MovementProviderOptions,
|
||||
PieceMovement,
|
||||
ArpeggioMovementConfig,
|
||||
ArpeggioMergeMovementConfig,
|
||||
|
||||
@ -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;
|
||||
|
||||
20
src/features/interactive/actionDispatcher.ts
Normal file
20
src/features/interactive/actionDispatcher.ts
Normal 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);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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)) {
|
||||
|
||||
@ -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';
|
||||
|
||||
|
||||
123
src/features/tasks/list/instructMode.ts
Normal file
123
src/features/tasks/list/instructMode.ts
Normal 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 };
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@ -52,6 +52,7 @@ export class ClaudeClient {
|
||||
bypassPermissions: options.bypassPermissions,
|
||||
anthropicApiKey: options.anthropicApiKey,
|
||||
outputSchema: options.outputSchema,
|
||||
sandbox: options.sandbox,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -95,6 +95,10 @@ export class SdkOptionsBuilder {
|
||||
sdkOptions.stderr = this.options.onStderr;
|
||||
}
|
||||
|
||||
if (this.options.sandbox) {
|
||||
sdkOptions.sandbox = this.options.sandbox;
|
||||
}
|
||||
|
||||
return sdkOptions;
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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). */
|
||||
|
||||
@ -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 */
|
||||
|
||||
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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})"
|
||||
|
||||
@ -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})"
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user