diff --git a/CHANGELOG.md b/CHANGELOG.md index 75f9acd..f3e26ef 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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)を削除 diff --git a/OPENCODE_CONFIG_CONTENT b/OPENCODE_CONFIG_CONTENT new file mode 100644 index 0000000..6b5dea8 --- /dev/null +++ b/OPENCODE_CONFIG_CONTENT @@ -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"} \ No newline at end of file diff --git a/builtins/en/personas/coder.md b/builtins/en/personas/coder.md index f222620..0662f29 100644 --- a/builtins/en/personas/coder.md +++ b/builtins/en/personas/coder.md @@ -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) diff --git a/builtins/en/personas/expert-supervisor.md b/builtins/en/personas/expert-supervisor.md index 6f6c7a7..7a9666d 100644 --- a/builtins/en/personas/expert-supervisor.md +++ b/builtins/en/personas/expert-supervisor.md @@ -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:** diff --git a/builtins/en/personas/planner.md b/builtins/en/personas/planner.md index fb858be..9454980 100644 --- a/builtins/en/personas/planner.md +++ b/builtins/en/personas/planner.md @@ -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 diff --git a/builtins/en/personas/supervisor.md b/builtins/en/personas/supervisor.md index 586d1d2..78c2b78 100644 --- a/builtins/en/personas/supervisor.md +++ b/builtins/en/personas/supervisor.md @@ -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 diff --git a/builtins/ja/personas/coder.md b/builtins/ja/personas/coder.md index ad81de3..afed754 100644 --- a/builtins/ja/personas/coder.md +++ b/builtins/ja/personas/coder.md @@ -35,3 +35,4 @@ - 後方互換・Legacy 対応を勝手に追加する → 絶対禁止 - リファクタリングで置き換えたコード・エクスポートを残す → 禁止(明示的に残すよう指示されない限り削除する) - 根本原因を修正した上で安全機構を迂回するワークアラウンドを重ねる → 禁止 +- タスク指示書にない既存機能の削除・構造変更を「ついでに」行う → 禁止(計画に含まれていても、指示書に根拠がない大規模削除は報告する) diff --git a/builtins/ja/personas/expert-supervisor.md b/builtins/ja/personas/expert-supervisor.md index dfb7282..3d234bc 100644 --- a/builtins/ja/personas/expert-supervisor.md +++ b/builtins/ja/personas/expert-supervisor.md @@ -43,6 +43,23 @@ | 非機能要件 | パフォーマンス、セキュリティ等は満たされているか | | スコープ | 要求以上のことをしていないか(スコープクリープ) | +### スコープクリープの検出(削除は最重要チェック) + +ファイルの**削除**と既存機能の**除去**はスコープクリープの最も危険な形態。 +追加は元に戻せるが、削除されたフローの復元は困難。 + +**必須手順:** +1. 変更差分から削除されたファイル(D)と削除されたクラス・メソッド・エンドポイントを列挙する +2. 各削除がタスク指示書のどの項目に対応するかを照合する +3. タスク指示書に根拠がない削除は REJECT する + +**典型的なスコープクリープ:** +- 「ステータス変更」タスクで Saga やエンドポイントが丸ごと削除されている +- 「UI修正」タスクでバックエンドのドメインモデルが構造変更されている +- 「表示変更」タスクでビジネスロジックのフローが書き換えられている + +レビュアーが「設計判断として妥当」と承認していても、タスク指示書のスコープ外であれば REJECT する。 + ### リスク評価 | 影響度\発生確率 | 低 | 中 | 高 | diff --git a/builtins/ja/personas/planner.md b/builtins/ja/personas/planner.md index d6afbcd..c246951 100644 --- a/builtins/ja/personas/planner.md +++ b/builtins/ja/personas/planner.md @@ -64,8 +64,19 @@ - 循環依存を作らない - 責務の分離(読み取りと書き込み、ビジネスロジックと IO) +### スコープ規律 + +タスク指示書に明記された作業のみを計画する。暗黙の「改善」を勝手に含めない。 + +**削除の判断基準:** +- **今回の変更で新たに未使用になったコード** → 削除を計画してよい(例: リネームした旧変数) +- **既存の機能・フロー・エンドポイント・Saga・イベント** → タスク指示書で明示的に指示されない限り削除しない + +「ステータスを5つに変更する」は「enum値を書き換える」であり、「不要になったフローを丸ごと削除する」ではない。 +タスク指示書の文言を拡大解釈しない。書かれていることだけを計画する。 + ### 計画の原則 - 後方互換コードは計画に含めない(明示的な指示がない限り不要) -- 使われていないものは削除する計画を立てる +- 今回の変更で新たに未使用になったコードは削除する計画を立てる - TODO コメントで済ませる計画は立てない。今やるか、やらないか diff --git a/builtins/ja/personas/supervisor.md b/builtins/ja/personas/supervisor.md index 5f2211f..b9b22c7 100644 --- a/builtins/ja/personas/supervisor.md +++ b/builtins/ja/personas/supervisor.md @@ -102,6 +102,21 @@ 「機能的に無害」は免罪符ではない。修正コストがほぼゼロの指摘を「非ブロッキング」「次回タスク」に分類することは妥協である。レビュアーが発見し、数分以内に修正できる問題は今回のタスクで修正させる。 +### スコープクリープの検出(削除は最重要チェック) + +ファイルの**削除**と既存機能の**除去**はスコープクリープの最も危険な形態。 +追加は元に戻せるが、削除されたフローの復元は困難。 + +**必須手順:** +1. 変更差分から削除されたファイル(D)と削除されたクラス・メソッド・エンドポイントを列挙する +2. 各削除がタスク指示書のどの項目に対応するかを照合する +3. タスク指示書に根拠がない削除は REJECT する + +**典型的なスコープクリープ:** +- 「ステータス変更」タスクで Saga やエンドポイントが丸ごと削除されている +- 「UI修正」タスクでバックエンドのドメインモデルが構造変更されている +- 「表示変更」タスクでビジネスロジックのフローが書き換えられている + ### ピース全体の見直し レポートディレクトリ内の全レポートを確認し、ピース全体の整合性をチェックする。 diff --git a/docs/provider-sandbox.md b/docs/provider-sandbox.md new file mode 100644 index 0000000..9553c00 --- /dev/null +++ b/docs/provider-sandbox.md @@ -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. diff --git a/e2e/helpers/isolated-env.ts b/e2e/helpers/isolated-env.ts index 8f1e24d..4e76281 100644 --- a/e2e/helpers/isolated-env.ts +++ b/e2e/helpers/isolated-env.ts @@ -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 { diff --git a/package-lock.json b/package-lock.json index 135f507..fa413ab 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 794d349..ce7fbf1 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/__tests__/actionDispatcher.test.ts b/src/__tests__/actionDispatcher.test.ts new file mode 100644 index 0000000..59671e7 --- /dev/null +++ b/src/__tests__/actionDispatcher.test.ts @@ -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); + }); +}); + diff --git a/src/__tests__/instructMode.test.ts b/src/__tests__/instructMode.test.ts new file mode 100644 index 0000000..b01d1d1 --- /dev/null +++ b/src/__tests__/instructMode.test.ts @@ -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>()), + 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>()), + 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'); + }); +}); diff --git a/src/__tests__/piece-selection.test.ts b/src/__tests__/piece-selection.test.ts index a05088a..94ab056 100644 --- a/src/__tests__/piece-selection.test.ts +++ b/src/__tests__/piece-selection.test.ts @@ -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(); + }); +}); diff --git a/src/__tests__/provider-options-piece-parser.test.ts b/src/__tests__/provider-options-piece-parser.test.ts index 45d7adc..79e36b1 100644 --- a/src/__tests__/provider-options-piece-parser.test.ts +++ b/src/__tests__/provider-options-piece-parser.test.ts @@ -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(); + }); }); diff --git a/src/__tests__/saveTaskFile.test.ts b/src/__tests__/saveTaskFile.test.ts index f4edfb1..54d0e91 100644 --- a/src/__tests__/saveTaskFile.test.ts +++ b/src/__tests__/saveTaskFile.test.ts @@ -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); + }); + }); }); diff --git a/src/__tests__/selectAndExecute-autoPr.test.ts b/src/__tests__/selectAndExecute-autoPr.test.ts index 2aad356..b483e04 100644 --- a/src/__tests__/selectAndExecute-autoPr.test.ts +++ b/src/__tests__/selectAndExecute-autoPr.test.ts @@ -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'); }); }); diff --git a/src/__tests__/switchPiece.test.ts b/src/__tests__/switchPiece.test.ts index 44ab51a..4f1857c 100644 --- a/src/__tests__/switchPiece.test.ts +++ b/src/__tests__/switchPiece.test.ts @@ -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', - 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); + it('should call selectPiece with fallbackToDefault: false', async () => { + mockSelectPiece.mockResolvedValue(null); - // When const switched = await switchPiece('/project'); - // Then expect(switched).toBe(false); - expect(mockWarnMissingPieces).toHaveBeenCalledWith([ - { categoryPath: ['Custom'], pieceName: 'my-missing', source: 'user' }, - ]); + 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, + }); + + const switched = await switchPiece('/project'); + + expect(switched).toBe(true); + expect(mockSetCurrentPiece).toHaveBeenCalledWith('/project', 'new-piece'); }); }); diff --git a/src/agents/runner.ts b/src/agents/runner.ts index 80952cd..75691a6 100644 --- a/src/agents/runner.ts +++ b/src/agents/runner.ts @@ -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, diff --git a/src/agents/types.ts b/src/agents/types.ts index da7cea0..a7cbd06 100644 --- a/src/agents/types.ts +++ b/src/agents/types.ts @@ -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; diff --git a/src/app/cli/routing.ts b/src/app/cli/routing.ts index 140b16e..d4626a2 100644 --- a/src/app/cli/routing.ts +++ b/src/app/cli/routing.ts @@ -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 { } } - 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); - if (issueNumber !== undefined) { - await saveTaskFromInteractive(resolvedCwd, result.task, pieceId, { - issue: issueNumber, - confirmAtEndMessage: 'Add this issue to tasks?', - }); - } + 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, 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 diff --git a/src/core/models/global-config.ts b/src/core/models/global-config.ts index 7ab9db5..8e90589 100644 --- a/src/core/models/global-config.ts +++ b/src/core/models/global-config.ts @@ -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; + /** 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; } diff --git a/src/core/models/index.ts b/src/core/models/index.ts index 9177e1b..9cd5116 100644 --- a/src/core/models/index.ts +++ b/src/core/models/index.ts @@ -14,6 +14,7 @@ export type { PartResult, TeamLeaderConfig, PieceRule, + MovementProviderOptions, PieceMovement, ArpeggioMovementConfig, ArpeggioMergeMovementConfig, diff --git a/src/core/models/piece-types.ts b/src/core/models/piece-types.ts index 5a02088..8449032 100644 --- a/src/core/models/piece-types.ts +++ b/src/core/models/piece-types.ts @@ -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 */ diff --git a/src/core/models/schemas.ts b/src/core/models/schemas.ts index 82a3dde..b23ff3b 100644 --- a/src/core/models/schemas.ts +++ b/src/core/models/schemas.ts @@ -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, }); diff --git a/src/core/models/types.ts b/src/core/models/types.ts index 84b8340..ce59af8 100644 --- a/src/core/models/types.ts +++ b/src/core/models/types.ts @@ -37,6 +37,7 @@ export type { OutputContractItem, OutputContractEntry, McpServerConfig, + MovementProviderOptions, PieceMovement, ArpeggioMovementConfig, ArpeggioMergeMovementConfig, diff --git a/src/features/config/switchPiece.ts b/src/features/config/switchPiece.ts index 5fab782..d2b26c1 100644 --- a/src/features/config/switchPiece.ts +++ b/src/features/config/switchPiece.ts @@ -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 { - // 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; diff --git a/src/features/interactive/actionDispatcher.ts b/src/features/interactive/actionDispatcher.ts new file mode 100644 index 0000000..4b5d23a --- /dev/null +++ b/src/features/interactive/actionDispatcher.ts @@ -0,0 +1,20 @@ +/** + * Shared dispatcher for post-conversation actions. + */ + +export interface ConversationActionResult { + action: A; + task: string; +} + +export type ConversationActionHandler = ( + result: ConversationActionResult, +) => Promise | R; + +export async function dispatchConversationAction( + result: ConversationActionResult, + handlers: Record>, +): Promise { + return handlers[result.action](result); +} + diff --git a/src/features/interactive/conversationLoop.ts b/src/features/interactive/conversationLoop.ts index 156862f..fb988ef 100644 --- a/src/features/interactive/conversationLoop.ts +++ b/src/features/interactive/conversationLoop.ts @@ -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; } /** @@ -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; diff --git a/src/features/interactive/interactive.ts b/src/features/interactive/interactive.ts index 36b9104..bc31775 100644 --- a/src/features/interactive/interactive.ts +++ b/src/features/interactive/interactive.ts @@ -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(); + 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 { blankLine(); info(proposedLabel); console.log(task); - return selectOption(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(actionPrompt, options); +} + +export async function selectPostSummaryAction( + task: string, + proposedLabel: string, + ui: InteractiveUIText, +): Promise { + 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'; diff --git a/src/features/pieceSelection/index.ts b/src/features/pieceSelection/index.ts index 689c096..67cfa98 100644 --- a/src/features/pieceSelection/index.ts +++ b/src/features/pieceSelection/index.ts @@ -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 { + 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); +} diff --git a/src/features/tasks/add/index.ts b/src/features/tasks/add/index.ts index cd48f40..3fe042f 100644 --- a/src/features/tasks/add/index.ts +++ b/src/features/tasks/add/index.ts @@ -151,13 +151,13 @@ export async function saveTaskFromInteractive( piece?: string, options?: { issue?: number; confirmAtEndMessage?: string }, ): Promise { - 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); } diff --git a/src/features/tasks/execute/selectAndExecute.ts b/src/features/tasks/execute/selectAndExecute.ts index 5816921..3014634 100644 --- a/src/features/tasks/execute/selectAndExecute.ts +++ b/src/features/tasks/execute/selectAndExecute.ts @@ -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 { - 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 { - 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 { if (override) { if (isPiecePath(override)) { diff --git a/src/features/tasks/list/index.ts b/src/features/tasks/list/index.ts index 4d26732..287962b 100644 --- a/src/features/tasks/list/index.ts +++ b/src/features/tasks/list/index.ts @@ -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'; diff --git a/src/features/tasks/list/instructMode.ts b/src/features/tasks/list/instructMode.ts new file mode 100644 index 0000000..9d83096 --- /dev/null +++ b/src/features/tasks/list/instructMode.ts @@ -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 { + return async (task: string, _lang: 'en' | 'ja'): Promise => { + 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 { + 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('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 }; +} diff --git a/src/features/tasks/list/taskActions.ts b/src/features/tasks/list/taskActions.ts index 2315529..d062c88 100644 --- a/src/features/tasks/list/taskActions.ts +++ b/src/features/tasks/list/taskActions.ts @@ -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 { - 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,54 +315,81 @@ export async function instructBranch( ): Promise { 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); - if (!selectedPiece) { - info('Cancelled'); - return false; - } - - log.info('Instructing branch via temp clone', { branch, piece: selectedPiece }); - info(`Running instruction on ${branch}...`); - - const clone = createTempCloneForBranch(projectDir, branch); - - try { - const branchContext = getBranchContext(projectDir, branch); - const fullInstruction = branchContext - ? `${branchContext}## 追加指示\n${instruction}` - : instruction; - - const taskSuccess = await executeTask({ - task: fullInstruction, - cwd: clone.path, - pieceIdentifier: selectedPiece, - projectCwd: projectDir, - agentOverrides: options, - }); - - if (taskSuccess) { - const commitResult = autoCommitAndPush(clone.path, item.taskSlug, projectDir); - if (commitResult.success && commitResult.commitHash) { - info(`Auto-committed & pushed: ${commitResult.commitHash}`); - } else if (!commitResult.success) { - warn(`Auto-commit skipped: ${commitResult.message}`); - } - success(`Instruction completed on ${branch}`); - log.info('Instruction completed', { branch }); - } else { - logError(`Instruction failed on ${branch}`); - log.error('Instruction failed', { branch }); + const ensurePieceSelected = async (): Promise => { + if (selectedPiece) { + return selectedPiece; } + selectedPiece = await selectPiece(projectDir); + if (!selectedPiece) { + info('Cancelled'); + return null; + } + return selectedPiece; + }; - return taskSuccess; - } finally { - removeClone(clone.path); - removeCloneMeta(projectDir, branch); - } + 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 }); + info(`Running instruction on ${branch}...`); + + const clone = createTempCloneForBranch(projectDir, branch); + + try { + const fullInstruction = branchContext + ? `${branchContext}## 追加指示\n${task}` + : task; + + const taskSuccess = await executeTask({ + task: fullInstruction, + cwd: clone.path, + pieceIdentifier: piece, + projectCwd: projectDir, + agentOverrides: options, + }); + + if (taskSuccess) { + const commitResult = autoCommitAndPush(clone.path, item.taskSlug, projectDir); + if (commitResult.success && commitResult.commitHash) { + info(`Auto-committed & pushed: ${commitResult.commitHash}`); + } else if (!commitResult.success) { + warn(`Auto-commit skipped: ${commitResult.message}`); + } + success(`Instruction completed on ${branch}`); + log.info('Instruction completed', { branch }); + } else { + logError(`Instruction failed on ${branch}`); + log.error('Instruction failed', { branch }); + } + + return taskSuccess; + } finally { + removeClone(clone.path); + removeCloneMeta(projectDir, branch); + } + }, + }); } diff --git a/src/infra/claude/client.ts b/src/infra/claude/client.ts index cb568e5..03da8c5 100644 --- a/src/infra/claude/client.ts +++ b/src/infra/claude/client.ts @@ -52,6 +52,7 @@ export class ClaudeClient { bypassPermissions: options.bypassPermissions, anthropicApiKey: options.anthropicApiKey, outputSchema: options.outputSchema, + sandbox: options.sandbox, }; } diff --git a/src/infra/claude/options-builder.ts b/src/infra/claude/options-builder.ts index dac37ab..9cf2ce6 100644 --- a/src/infra/claude/options-builder.ts +++ b/src/infra/claude/options-builder.ts @@ -95,6 +95,10 @@ export class SdkOptionsBuilder { sdkOptions.stderr = this.options.onStderr; } + if (this.options.sandbox) { + sdkOptions.sandbox = this.options.sandbox; + } + return sdkOptions; } diff --git a/src/infra/claude/types.ts b/src/infra/claude/types.ts index 1c19741..67114c6 100644 --- a/src/infra/claude/types.ts +++ b/src/infra/claude/types.ts @@ -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; + /** 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; /** Callback for stderr output from the Claude Code process */ onStderr?: (data: string) => void; + /** Sandbox settings for Claude SDK */ + sandbox?: SandboxSettings; } diff --git a/src/infra/config/global/globalConfig.ts b/src/infra/config/global/globalConfig.ts index 763f138..4d86a5c 100644 --- a/src/infra/config/global/globalConfig.ts +++ b/src/infra/config/global/globalConfig.ts @@ -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, diff --git a/src/infra/config/loaders/pieceParser.ts b/src/infra/config/loaders/pieceParser.ts index 83df5dc..525fc04 100644 --- a/src/infra/config/loaders/pieceParser.ts +++ b/src/infra/config/loaders/pieceParser.ts @@ -24,34 +24,61 @@ import { type RawStep = z.output; -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). */ diff --git a/src/infra/config/types.ts b/src/infra/config/types.ts index f29d537..334d105 100644 --- a/src/infra/config/types.ts +++ b/src/infra/config/types.ts @@ -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; /** Show uncategorized pieces under Others category */ diff --git a/src/infra/providers/claude.ts b/src/infra/providers/claude.ts index a47702f..962afd5 100644 --- a/src/infra/providers/claude.ts +++ b/src/infra/providers/claude.ts @@ -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, }; } diff --git a/src/infra/providers/types.ts b/src/infra/providers/types.ts index b47c2e8..9560cf2 100644 --- a/src/infra/providers/types.ts +++ b/src/infra/providers/types.ts @@ -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; diff --git a/src/shared/i18n/labels_en.yaml b/src/shared/i18n/labels_en.yaml index 0d76f2f..607eafa 100644 --- a/src/shared/i18n/labels_en.yaml +++ b/src/shared/i18n/labels_en.yaml @@ -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})" diff --git a/src/shared/i18n/labels_ja.yaml b/src/shared/i18n/labels_ja.yaml index 2bc9ed0..eac4cd8 100644 --- a/src/shared/i18n/labels_ja.yaml +++ b/src/shared/i18n/labels_ja.yaml @@ -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})"