Merge pull request #272 from nrslib/release/v0.14.0

Release v0.14.0
This commit is contained in:
nrs 2026-02-14 12:25:01 +09:00 committed by GitHub
commit dc5dda1afb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
86 changed files with 3593 additions and 1353 deletions

4
.gitignore vendored
View File

@ -33,3 +33,7 @@ coverage/
task_planning/ task_planning/
OPENCODE_CONFIG_CONTENT
# Local editor/agent settings
.claude/

View File

@ -4,6 +4,37 @@ 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/). The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
## [0.14.0] - 2026-02-14
### Added
- **`takt list` インストラクトモード (#267)**: 既存ブランチに対して追加指示を行えるインストラクトモードを追加 — 会話ループで要件を詳細化してからピース実行が可能に
- **`takt list` 完了タスクアクション (#271)**: 完了タスクに対する diff 表示・ブランチ操作(マージ、削除)を追加
- **Claude サンドボックス設定**: `provider_options.claude.sandbox` でサンドボックスの除外コマンド(`excluded_commands`)やサンドボックス無効化(`allow_unsandboxed_commands`)を設定可能に
- **`provider_options` のグローバル/プロジェクト設定**: `provider_options``~/.takt/config.yaml`(グローバル)および `.takt/config.yaml`(プロジェクト)で設定可能に — ピースレベル設定の最低優先フォールバックとして機能
### Changed
- **provider/model の解決ロジックを AgentRunner に集約**: provider 解決でプロジェクト設定をカスタムエージェント設定より優先するよう修正。ステップレベルの `stepModel` / `stepProvider` による上書きを追加
- **ポストエクスキューションの共通化**: インタラクティブモードとインストラクトモードで post-execution フローauto-commit, push, PR 作成)を `postExecution.ts` に共通化
- **スコープ縮小防止策をインストラクションに追加**: plan, ai-review, supervise のインストラクションに要件の取りこぼし検出を追加 — plan では要件ごとの「変更要/不要」判定と根拠提示を必須化、supervise では計画レポートの鵜呑み禁止
### Fixed
- インタラクティブモードの選択肢が非同期実行時に表示されてしまうバグを修正 (#266)
- OpenCode のパラレル実行時にセッション ID を引き継げない問題を修正 — サーバーをシングルトン化し並列実行時の競合を解消
- OpenCode SDK サーバー起動タイムアウトを 30 秒から 60 秒に延長
### Internal
- タスク管理の大規模リファクタリング: `TaskRunner` の責務を `TaskLifecycleService``TaskDeletionService``TaskQueryService` に分離
- `taskActions.ts` を機能別に分割: `taskBranchLifecycleActions.ts``taskDiffActions.ts``taskInstructionActions.ts``taskDeleteActions.ts`
- `postExecution.ts``taskResultHandler.ts``instructMode.ts``taskActionTarget.ts` を新規追加
- ピース選択ロジックを `pieceSelection/index.ts` に集約(`selectAndExecute.ts` から抽出)
- テスト追加: instructMode, listNonInteractive-completedActions, listTasksInteractiveStatusActions, option-resolution-order, taskInstructionActions, selectAndExecute-autoPr 等を新規・拡充
- E2E テストに Claude Code サンドボックス対応オプション(`dangerouslyDisableSandbox`)を追加
- `OPENCODE_CONFIG_CONTENT``.gitignore` に追加
## [0.13.0] - 2026-02-13 ## [0.13.0] - 2026-02-13
### Added ### Added

View File

@ -8,6 +8,7 @@ Review the code for AI-specific issues:
- Plausible but incorrect patterns - Plausible but incorrect patterns
- Compatibility with the existing codebase - Compatibility with the existing codebase
- Scope creep detection - Scope creep detection
- Scope shrinkage detection (missing task requirements)
## Judgment Procedure ## Judgment Procedure

View File

@ -12,6 +12,7 @@ For small tasks, skip the design sections in the report.
**Actions:** **Actions:**
1. Understand the task requirements 1. Understand the task requirements
- **For each requirement, determine "change needed / not needed". If "not needed", cite the relevant code (file:line) as evidence. Claiming "already correct" without evidence is prohibited**
2. Investigate code to resolve unknowns 2. Investigate code to resolve unknowns
3. Identify the impact area 3. Identify the impact area
4. Determine file structure and design patterns (if needed) 4. Determine file structure and design patterns (if needed)

View File

@ -3,7 +3,8 @@ Run tests, verify the build, and perform final approval.
**Overall piece verification:** **Overall piece verification:**
1. Whether the plan and implementation results are consistent 1. Whether the plan and implementation results are consistent
2. Whether findings from each review movement have been addressed 2. Whether findings from each review movement have been addressed
3. Whether the original task objective has been achieved 3. Whether each task spec requirement has been achieved
- Do not rely on the plan report's judgment; independently verify each requirement against actual code (file:line)
**Report verification:** Read all reports in the Report Directory and **Report verification:** Read all reports in the Report Directory and
check for any unaddressed improvement suggestions. check for any unaddressed improvement suggestions.

View File

@ -8,6 +8,7 @@ AI特有の問題についてコードをレビューしてください:
- もっともらしいが間違っているパターン - もっともらしいが間違っているパターン
- 既存コードベースとの適合性 - 既存コードベースとの適合性
- スコープクリープの検出 - スコープクリープの検出
- スコープ縮小の検出(タスク要件の取りこぼし)
## 判定手順 ## 判定手順

View File

@ -18,6 +18,7 @@
- **指示書に明記されていない別ファイルを「参照資料の代わり」として使うことは禁止** - **指示書に明記されていない別ファイルを「参照資料の代わり」として使うことは禁止**
2. タスクの要件を理解する 2. タスクの要件を理解する
- 参照資料の内容と現在の実装を突き合わせて差分を特定する - 参照資料の内容と現在の実装を突き合わせて差分を特定する
- **要件ごとに「変更要/不要」を判定する。「不要」の場合は現行コードの該当箇所(ファイル:行)を根拠として示すこと。根拠なしの「既に正しい」は禁止**
3. コードを調査して不明点を解決する 3. コードを調査して不明点を解決する
4. 影響範囲を特定する 4. 影響範囲を特定する
5. ファイル構成・設計パターンを決定する(必要な場合) 5. ファイル構成・設計パターンを決定する(必要な場合)

View File

@ -3,7 +3,8 @@
**ピース全体の確認:** **ピース全体の確認:**
1. 計画と実装結果が一致しているか 1. 計画と実装結果が一致しているか
2. 各レビュームーブメントの指摘が対応されているか 2. 各レビュームーブメントの指摘が対応されているか
3. 元のタスク目的が達成されているか 3. タスク指示書の各要件が達成されているか
- 計画レポートの判断を鵜呑みにせず、要件ごとに実コード(ファイル:行)で独立照合する
**レポートの確認:** Report Directory内の全レポートを読み、 **レポートの確認:** Report Directory内の全レポートを読み、
未対応の改善提案がないか確認してください。 未対応の改善提案がないか確認してください。

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

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

View File

@ -221,7 +221,6 @@ describe('E2E: Run tasks graceful shutdown on SIGINT (parallel)', () => {
env: { env: {
...isolatedEnv.env, ...isolatedEnv.env,
TAKT_MOCK_SCENARIO: scenarioPath, TAKT_MOCK_SCENARIO: scenarioPath,
TAKT_E2E_SELF_SIGINT_TWICE: '1',
}, },
stdio: ['ignore', 'pipe', 'pipe'], stdio: ['ignore', 'pipe', 'pipe'],
}); });
@ -242,9 +241,14 @@ describe('E2E: Run tasks graceful shutdown on SIGINT (parallel)', () => {
); );
expect(workersFilled, `stdout:\n${stdout}\n\nstderr:\n${stderr}`).toBe(true); expect(workersFilled, `stdout:\n${stdout}\n\nstderr:\n${stderr}`).toBe(true);
// Simulate user pressing Ctrl+C twice.
child.kill('SIGINT');
await new Promise((resolvePromise) => setTimeout(resolvePromise, 25));
child.kill('SIGINT');
const exit = await waitForClose(child, 60_000); const exit = await waitForClose(child, 60_000);
expect( expect(
exit.signal === 'SIGINT' || exit.code === 130, exit.signal === 'SIGINT' || exit.code === 130 || exit.code === 0,
`unexpected exit: code=${exit.code}, signal=${exit.signal}`, `unexpected exit: code=${exit.code}, signal=${exit.signal}`,
).toBe(true); ).toBe(true);

4
package-lock.json generated
View File

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

View File

@ -1,6 +1,6 @@
{ {
"name": "takt", "name": "takt",
"version": "0.13.0", "version": "0.14.0",
"description": "TAKT: TAKT Agent Koordination Topology - AI Agent Piece Orchestration", "description": "TAKT: TAKT Agent Koordination Topology - AI Agent Piece Orchestration",
"main": "dist/index.js", "main": "dist/index.js",
"types": "dist/index.d.ts", "types": "dist/index.d.ts",
@ -14,7 +14,7 @@
"watch": "tsc --watch", "watch": "tsc --watch",
"test": "vitest run", "test": "vitest run",
"test:watch": "vitest", "test:watch": "vitest",
"test:e2e": "npm run test:e2e:mock; code=$?; if [ \"$code\" -eq 0 ]; then msg='test:e2e passed'; else msg=\"test:e2e failed (exit=$code)\"; fi; if command -v osascript >/dev/null 2>&1; then osascript -e \"display notification \\\"$msg\\\" with title \\\"takt\\\" subtitle \\\"E2E\\\"\" >/dev/null 2>&1 || true; fi; echo \"[takt] $msg\"; exit $code", "test:e2e": "tmp=\"$(mktemp -t takt-e2e.XXXXXX)\"; npm run test:e2e:mock >\"$tmp\" 2>&1; code=$?; cat \"$tmp\"; if grep -q \"error connecting to api.github.com\" \"$tmp\"; then echo \"[takt] GitHub connectivity error detected in E2E output\"; code=1; fi; rm -f \"$tmp\"; if [ \"$code\" -eq 0 ]; then msg='test:e2e passed'; else msg=\"test:e2e failed (exit=$code)\"; fi; if command -v osascript >/dev/null 2>&1; then osascript -e \"display notification \\\"$msg\\\" with title \\\"takt\\\" subtitle \\\"E2E\\\"\" >/dev/null 2>&1 || true; fi; echo \"[takt] $msg\"; exit $code",
"test:e2e:mock": "TAKT_E2E_PROVIDER=mock vitest run --config vitest.config.e2e.mock.ts --reporter=verbose", "test:e2e:mock": "TAKT_E2E_PROVIDER=mock vitest run --config vitest.config.e2e.mock.ts --reporter=verbose",
"test:e2e:all": "npm run test:e2e:mock && npm run test:e2e:provider", "test:e2e:all": "npm run test:e2e:mock && npm run test:e2e:provider",
"test:e2e:provider": "npm run test:e2e:provider:claude && npm run test:e2e:provider:codex", "test:e2e:provider": "npm run test:e2e:provider:claude && npm run test:e2e:provider:codex",

View File

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

View File

@ -1,8 +1,8 @@
/** /**
* Tests for PieceEngine provider/model overrides. * Tests for PieceEngine provider/model overrides.
* *
* Verifies that CLI-specified overrides take precedence over piece movement defaults, * Verifies that PieceEngine passes CLI-level and movement-level provider/model
* and that movement-specific values are used when no overrides are present. * as separate fields to AgentRunner.
*/ */
import { describe, it, expect, beforeEach, vi } from 'vitest'; import { describe, it, expect, beforeEach, vi } from 'vitest';
@ -44,7 +44,7 @@ describe('PieceEngine agent overrides', () => {
applyDefaultMocks(); applyDefaultMocks();
}); });
it('respects piece movement provider/model even when CLI overrides are provided', async () => { it('passes CLI provider/model and movement provider/model separately', async () => {
const movement = makeMovement('plan', { const movement = makeMovement('plan', {
provider: 'claude', provider: 'claude',
model: 'claude-movement', model: 'claude-movement',
@ -71,11 +71,13 @@ describe('PieceEngine agent overrides', () => {
await engine.run(); await engine.run();
const options = vi.mocked(runAgent).mock.calls[0][2]; const options = vi.mocked(runAgent).mock.calls[0][2];
expect(options.provider).toBe('claude'); expect(options.provider).toBe('codex');
expect(options.model).toBe('claude-movement'); expect(options.model).toBe('cli-model');
expect(options.stepProvider).toBe('claude');
expect(options.stepModel).toBe('claude-movement');
}); });
it('allows CLI overrides when piece movement leaves provider/model undefined', async () => { it('uses CLI provider/model when movement provider/model is undefined', async () => {
const movement = makeMovement('plan', { const movement = makeMovement('plan', {
rules: [makeRule('done', 'COMPLETE')], rules: [makeRule('done', 'COMPLETE')],
}); });
@ -102,9 +104,11 @@ describe('PieceEngine agent overrides', () => {
const options = vi.mocked(runAgent).mock.calls[0][2]; const options = vi.mocked(runAgent).mock.calls[0][2];
expect(options.provider).toBe('codex'); expect(options.provider).toBe('codex');
expect(options.model).toBe('cli-model'); expect(options.model).toBe('cli-model');
expect(options.stepProvider).toBe('codex');
expect(options.stepModel).toBe('cli-model');
}); });
it('falls back to piece movement provider/model when no overrides supplied', async () => { it('sets movement provider/model to step fields when no CLI overrides are supplied', async () => {
const movement = makeMovement('plan', { const movement = makeMovement('plan', {
provider: 'claude', provider: 'claude',
model: 'movement-model', model: 'movement-model',
@ -126,7 +130,9 @@ describe('PieceEngine agent overrides', () => {
await engine.run(); await engine.run();
const options = vi.mocked(runAgent).mock.calls[0][2]; const options = vi.mocked(runAgent).mock.calls[0][2];
expect(options.provider).toBe('claude'); expect(options.provider).toBeUndefined();
expect(options.model).toBe('movement-model'); expect(options.model).toBeUndefined();
expect(options.stepProvider).toBe('claude');
expect(options.stepModel).toBe('movement-model');
}); });
}); });

View File

@ -1,10 +1,10 @@
/** /**
* Tests for persona_providers config-level provider override. * Tests for persona_providers config-level provider override.
* *
* Verifies the provider resolution priority: * Verifies movement-level provider resolution for stepProvider:
* 1. Movement YAML provider (highest) * 1. Movement YAML provider (highest)
* 2. persona_providers[personaDisplayName] * 2. persona_providers[personaDisplayName]
* 3. CLI/global provider (lowest) * 3. CLI provider (lowest)
*/ */
import { describe, it, expect, beforeEach, vi } from 'vitest'; import { describe, it, expect, beforeEach, vi } from 'vitest';
@ -72,7 +72,8 @@ describe('PieceEngine persona_providers override', () => {
await engine.run(); await engine.run();
const options = vi.mocked(runAgent).mock.calls[0][2]; const options = vi.mocked(runAgent).mock.calls[0][2];
expect(options.provider).toBe('codex'); expect(options.provider).toBe('claude');
expect(options.stepProvider).toBe('codex');
}); });
it('should use global provider when persona is not in persona_providers', async () => { it('should use global provider when persona is not in persona_providers', async () => {
@ -102,6 +103,7 @@ describe('PieceEngine persona_providers override', () => {
const options = vi.mocked(runAgent).mock.calls[0][2]; const options = vi.mocked(runAgent).mock.calls[0][2];
expect(options.provider).toBe('claude'); expect(options.provider).toBe('claude');
expect(options.stepProvider).toBe('claude');
}); });
it('should prioritize movement provider over persona_providers', async () => { it('should prioritize movement provider over persona_providers', async () => {
@ -131,7 +133,8 @@ describe('PieceEngine persona_providers override', () => {
await engine.run(); await engine.run();
const options = vi.mocked(runAgent).mock.calls[0][2]; const options = vi.mocked(runAgent).mock.calls[0][2];
expect(options.provider).toBe('claude'); expect(options.provider).toBe('mock');
expect(options.stepProvider).toBe('claude');
}); });
it('should work without persona_providers (undefined)', async () => { it('should work without persona_providers (undefined)', async () => {
@ -160,6 +163,7 @@ describe('PieceEngine persona_providers override', () => {
const options = vi.mocked(runAgent).mock.calls[0][2]; const options = vi.mocked(runAgent).mock.calls[0][2];
expect(options.provider).toBe('claude'); expect(options.provider).toBe('claude');
expect(options.stepProvider).toBe('claude');
}); });
it('should apply different providers to different personas in a multi-movement piece', async () => { it('should apply different providers to different personas in a multi-movement piece', async () => {
@ -196,9 +200,11 @@ describe('PieceEngine persona_providers override', () => {
await engine.run(); await engine.run();
const calls = vi.mocked(runAgent).mock.calls; const calls = vi.mocked(runAgent).mock.calls;
// Plan movement: planner not in persona_providers → claude // Plan movement: planner not in persona_providers → stepProvider は claude
expect(calls[0][2].provider).toBe('claude'); expect(calls[0][2].provider).toBe('claude');
// Implement movement: coder in persona_providers → codex expect(calls[0][2].stepProvider).toBe('claude');
expect(calls[1][2].provider).toBe('codex'); // Implement movement: coder in persona_providers → stepProvider は codex
expect(calls[1][2].provider).toBe('claude');
expect(calls[1][2].stepProvider).toBe('codex');
}); });
}); });

View File

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

View File

@ -0,0 +1,83 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const {
mockDeleteCompletedTask,
mockListAllTaskItems,
mockMergeBranch,
mockDeleteBranch,
mockInfo,
} = vi.hoisted(() => ({
mockDeleteCompletedTask: vi.fn(),
mockListAllTaskItems: vi.fn(),
mockMergeBranch: vi.fn(),
mockDeleteBranch: vi.fn(),
mockInfo: vi.fn(),
}));
vi.mock('../infra/task/index.js', () => ({
detectDefaultBranch: vi.fn(() => 'main'),
TaskRunner: class {
listAllTaskItems() {
return mockListAllTaskItems();
}
deleteCompletedTask(name: string) {
mockDeleteCompletedTask(name);
}
},
}));
vi.mock('../shared/ui/index.js', () => ({
info: (...args: unknown[]) => mockInfo(...args),
}));
vi.mock('../features/tasks/list/taskActions.js', () => ({
tryMergeBranch: vi.fn(),
mergeBranch: (...args: unknown[]) => mockMergeBranch(...args),
deleteBranch: (...args: unknown[]) => mockDeleteBranch(...args),
}));
import { listTasksNonInteractive } from '../features/tasks/list/listNonInteractive.js';
describe('listTasksNonInteractive completed actions', () => {
beforeEach(() => {
vi.clearAllMocks();
mockListAllTaskItems.mockReturnValue([
{
kind: 'completed',
name: 'completed-task',
createdAt: '2026-02-14T00:00:00.000Z',
filePath: '/project/.takt/tasks.yaml',
content: 'done',
branch: 'takt/completed-task',
},
]);
});
it('should delete completed record after merge action', async () => {
mockMergeBranch.mockReturnValue(true);
await listTasksNonInteractive('/project', {
enabled: true,
action: 'merge',
branch: 'takt/completed-task',
yes: true,
});
expect(mockMergeBranch).toHaveBeenCalled();
expect(mockDeleteCompletedTask).toHaveBeenCalledWith('completed-task');
});
it('should delete completed record after delete action', async () => {
mockDeleteBranch.mockReturnValue(true);
await listTasksNonInteractive('/project', {
enabled: true,
action: 'delete',
branch: 'takt/completed-task',
yes: true,
});
expect(mockDeleteBranch).toHaveBeenCalled();
expect(mockDeleteCompletedTask).toHaveBeenCalledWith('completed-task');
});
});

View File

@ -13,8 +13,6 @@ vi.mock('../shared/ui/index.js', () => ({
vi.mock('../infra/task/branchList.js', async (importOriginal) => ({ vi.mock('../infra/task/branchList.js', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()), ...(await importOriginal<Record<string, unknown>>()),
detectDefaultBranch: vi.fn(() => 'main'), detectDefaultBranch: vi.fn(() => 'main'),
listTaktBranches: vi.fn(() => []),
buildListItems: vi.fn(() => []),
})); }));
let tmpDir: string; let tmpDir: string;
@ -60,7 +58,7 @@ describe('listTasksNonInteractive', () => {
await listTasksNonInteractive(tmpDir, { enabled: true, format: 'text' }); await listTasksNonInteractive(tmpDir, { enabled: true, format: 'text' });
expect(mockInfo).toHaveBeenCalledWith(expect.stringContaining('[running] pending-task')); expect(mockInfo).toHaveBeenCalledWith(expect.stringContaining('[pending] pending-task'));
expect(mockInfo).toHaveBeenCalledWith(expect.stringContaining('[failed] failed-task')); expect(mockInfo).toHaveBeenCalledWith(expect.stringContaining('[failed] failed-task'));
}); });
@ -71,9 +69,11 @@ describe('listTasksNonInteractive', () => {
await listTasksNonInteractive(tmpDir, { enabled: true, format: 'json' }); await listTasksNonInteractive(tmpDir, { enabled: true, format: 'json' });
expect(logSpy).toHaveBeenCalledTimes(1); expect(logSpy).toHaveBeenCalledTimes(1);
const payload = JSON.parse(logSpy.mock.calls[0]![0] as string) as { pendingTasks: Array<{ name: string }>; failedTasks: Array<{ name: string }> }; const payload = JSON.parse(logSpy.mock.calls[0]![0] as string) as { tasks: Array<{ name: string; kind: string }> };
expect(payload.pendingTasks[0]?.name).toBe('pending-task'); expect(payload.tasks[0]?.name).toBe('pending-task');
expect(payload.failedTasks[0]?.name).toBe('failed-task'); expect(payload.tasks[0]?.kind).toBe('pending');
expect(payload.tasks[1]?.name).toBe('failed-task');
expect(payload.tasks[1]?.kind).toBe('failed');
logSpy.mockRestore(); logSpy.mockRestore();
}); });

View File

@ -12,8 +12,6 @@ vi.mock('../shared/ui/index.js', () => ({
vi.mock('../infra/task/branchList.js', async (importOriginal) => ({ vi.mock('../infra/task/branchList.js', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()), ...(await importOriginal<Record<string, unknown>>()),
listTaktBranches: vi.fn(() => []),
buildListItems: vi.fn(() => []),
detectDefaultBranch: vi.fn(() => 'main'), detectDefaultBranch: vi.fn(() => 'main'),
})); }));
@ -74,7 +72,7 @@ describe('TaskRunner list APIs', () => {
}); });
describe('listTasks non-interactive JSON output', () => { describe('listTasks non-interactive JSON output', () => {
it('should output JSON object with branches, pendingTasks, and failedTasks', async () => { it('should output JSON object with tasks', async () => {
writeTasksFile(tmpDir); writeTasksFile(tmpDir);
const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined); const logSpy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
@ -82,13 +80,13 @@ describe('listTasks non-interactive JSON output', () => {
expect(logSpy).toHaveBeenCalledTimes(1); expect(logSpy).toHaveBeenCalledTimes(1);
const payload = JSON.parse(logSpy.mock.calls[0]![0] as string) as { const payload = JSON.parse(logSpy.mock.calls[0]![0] as string) as {
branches: unknown[]; tasks: Array<{ name: string; kind: string }>;
pendingTasks: Array<{ name: string }>;
failedTasks: Array<{ name: string }>;
}; };
expect(Array.isArray(payload.branches)).toBe(true); expect(Array.isArray(payload.tasks)).toBe(true);
expect(payload.pendingTasks[0]?.name).toBe('pending-one'); expect(payload.tasks[0]?.name).toBe('pending-one');
expect(payload.failedTasks[0]?.name).toBe('failed-one'); expect(payload.tasks[0]?.kind).toBe('pending');
expect(payload.tasks[1]?.name).toBe('failed-one');
expect(payload.tasks[1]?.kind).toBe('failed');
logSpy.mockRestore(); logSpy.mockRestore();
}); });

View File

@ -6,38 +6,27 @@ const {
mockHeader, mockHeader,
mockInfo, mockInfo,
mockBlankLine, mockBlankLine,
mockConfirm, mockListAllTaskItems,
mockListPendingTaskItems,
mockListFailedTasks,
mockDeletePendingTask, mockDeletePendingTask,
} = vi.hoisted(() => ({ } = vi.hoisted(() => ({
mockSelectOption: vi.fn(), mockSelectOption: vi.fn(),
mockHeader: vi.fn(), mockHeader: vi.fn(),
mockInfo: vi.fn(), mockInfo: vi.fn(),
mockBlankLine: vi.fn(), mockBlankLine: vi.fn(),
mockConfirm: vi.fn(), mockListAllTaskItems: vi.fn(),
mockListPendingTaskItems: vi.fn(),
mockListFailedTasks: vi.fn(),
mockDeletePendingTask: vi.fn(), mockDeletePendingTask: vi.fn(),
})); }));
vi.mock('../infra/task/index.js', () => ({ vi.mock('../infra/task/index.js', () => ({
listTaktBranches: vi.fn(() => []),
buildListItems: vi.fn(() => []),
detectDefaultBranch: vi.fn(() => 'main'),
TaskRunner: class { TaskRunner: class {
listPendingTaskItems() { listAllTaskItems() {
return mockListPendingTaskItems(); return mockListAllTaskItems();
}
listFailedTasks() {
return mockListFailedTasks();
} }
}, },
})); }));
vi.mock('../shared/prompt/index.js', () => ({ vi.mock('../shared/prompt/index.js', () => ({
selectOption: mockSelectOption, selectOption: mockSelectOption,
confirm: mockConfirm,
})); }));
vi.mock('../shared/ui/index.js', () => ({ vi.mock('../shared/ui/index.js', () => ({
@ -48,7 +37,7 @@ vi.mock('../shared/ui/index.js', () => ({
vi.mock('../features/tasks/list/taskActions.js', () => ({ vi.mock('../features/tasks/list/taskActions.js', () => ({
showFullDiff: vi.fn(), showFullDiff: vi.fn(),
showDiffAndPromptAction: vi.fn(), showDiffAndPromptActionForTask: vi.fn(),
tryMergeBranch: vi.fn(), tryMergeBranch: vi.fn(),
mergeBranch: vi.fn(), mergeBranch: vi.fn(),
deleteBranch: vi.fn(), deleteBranch: vi.fn(),
@ -58,6 +47,7 @@ vi.mock('../features/tasks/list/taskActions.js', () => ({
vi.mock('../features/tasks/list/taskDeleteActions.js', () => ({ vi.mock('../features/tasks/list/taskDeleteActions.js', () => ({
deletePendingTask: mockDeletePendingTask, deletePendingTask: mockDeletePendingTask,
deleteFailedTask: vi.fn(), deleteFailedTask: vi.fn(),
deleteCompletedTask: vi.fn(),
})); }));
vi.mock('../features/tasks/list/taskRetryActions.js', () => ({ vi.mock('../features/tasks/list/taskRetryActions.js', () => ({
@ -77,23 +67,22 @@ describe('listTasks interactive pending label regression', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
mockListPendingTaskItems.mockReturnValue([pendingTask]); mockListAllTaskItems.mockReturnValue([pendingTask]);
mockListFailedTasks.mockReturnValue([]);
}); });
it('should show [running] in interactive menu for pending tasks', async () => { it('should show [pending] in interactive menu for pending tasks', async () => {
mockSelectOption.mockResolvedValueOnce(null); mockSelectOption.mockResolvedValueOnce(null);
await listTasks('/project'); await listTasks('/project');
expect(mockSelectOption).toHaveBeenCalledTimes(1); expect(mockSelectOption).toHaveBeenCalledTimes(1);
const menuOptions = mockSelectOption.mock.calls[0]![1] as Array<{ label: string; value: string }>; const menuOptions = mockSelectOption.mock.calls[0]![1] as Array<{ label: string; value: string }>;
expect(menuOptions).toContainEqual(expect.objectContaining({ label: '[running] my-task', value: 'pending:0' })); expect(menuOptions).toContainEqual(expect.objectContaining({ label: '[pending] my-task', value: 'pending:0' }));
expect(menuOptions.some((opt) => opt.label.includes('[pending]'))).toBe(false); expect(menuOptions.some((opt) => opt.label.includes('[running]'))).toBe(false);
expect(menuOptions.some((opt) => opt.label.includes('[pendig]'))).toBe(false); expect(menuOptions.some((opt) => opt.label.includes('[pendig]'))).toBe(false);
}); });
it('should show [running] header when pending task is selected', async () => { it('should show [pending] header when pending task is selected', async () => {
mockSelectOption mockSelectOption
.mockResolvedValueOnce('pending:0') .mockResolvedValueOnce('pending:0')
.mockResolvedValueOnce(null) .mockResolvedValueOnce(null)
@ -101,9 +90,9 @@ describe('listTasks interactive pending label regression', () => {
await listTasks('/project'); await listTasks('/project');
expect(mockHeader).toHaveBeenCalledWith('[running] my-task'); expect(mockHeader).toHaveBeenCalledWith('[pending] my-task');
const headerTexts = mockHeader.mock.calls.map(([text]) => String(text)); const headerTexts = mockHeader.mock.calls.map(([text]) => String(text));
expect(headerTexts.some((text) => text.includes('[pending]'))).toBe(false); expect(headerTexts.some((text) => text.includes('[running]'))).toBe(false);
expect(headerTexts.some((text) => text.includes('[pendig]'))).toBe(false); expect(headerTexts.some((text) => text.includes('[pendig]'))).toBe(false);
}); });
}); });

View File

@ -0,0 +1,147 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import type { TaskListItem } from '../infra/task/types.js';
const {
mockSelectOption,
mockHeader,
mockInfo,
mockBlankLine,
mockListAllTaskItems,
mockDeleteCompletedRecord,
mockShowDiffAndPromptActionForTask,
mockMergeBranch,
mockDeleteCompletedTask,
} = vi.hoisted(() => ({
mockSelectOption: vi.fn(),
mockHeader: vi.fn(),
mockInfo: vi.fn(),
mockBlankLine: vi.fn(),
mockListAllTaskItems: vi.fn(),
mockDeleteCompletedRecord: vi.fn(),
mockShowDiffAndPromptActionForTask: vi.fn(),
mockMergeBranch: vi.fn(),
mockDeleteCompletedTask: vi.fn(),
}));
vi.mock('../infra/task/index.js', () => ({
TaskRunner: class {
listAllTaskItems() {
return mockListAllTaskItems();
}
deleteCompletedTask(name: string) {
mockDeleteCompletedRecord(name);
}
},
}));
vi.mock('../shared/prompt/index.js', () => ({
selectOption: mockSelectOption,
}));
vi.mock('../shared/ui/index.js', () => ({
info: mockInfo,
header: mockHeader,
blankLine: mockBlankLine,
}));
vi.mock('../features/tasks/list/taskActions.js', () => ({
showFullDiff: vi.fn(),
showDiffAndPromptActionForTask: mockShowDiffAndPromptActionForTask,
tryMergeBranch: vi.fn(),
mergeBranch: mockMergeBranch,
deleteBranch: vi.fn(),
instructBranch: vi.fn(),
}));
vi.mock('../features/tasks/list/taskDeleteActions.js', () => ({
deletePendingTask: vi.fn(),
deleteFailedTask: vi.fn(),
deleteCompletedTask: mockDeleteCompletedTask,
}));
vi.mock('../features/tasks/list/taskRetryActions.js', () => ({
retryFailedTask: vi.fn(),
}));
import { listTasks } from '../features/tasks/list/index.js';
const runningTask: TaskListItem = {
kind: 'running',
name: 'running-task',
createdAt: '2026-02-14T00:00:00.000Z',
filePath: '/project/.takt/tasks.yaml',
content: 'in progress',
};
const completedTaskWithBranch: TaskListItem = {
kind: 'completed',
name: 'completed-task',
createdAt: '2026-02-14T00:00:00.000Z',
filePath: '/project/.takt/tasks.yaml',
content: 'done',
branch: 'takt/completed-task',
};
const completedTaskWithoutBranch: TaskListItem = {
...completedTaskWithBranch,
branch: undefined,
name: 'completed-without-branch',
};
describe('listTasks interactive status actions', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('running タスク選択時は read-only メッセージを表示する', async () => {
mockListAllTaskItems.mockReturnValue([runningTask]);
mockSelectOption
.mockResolvedValueOnce('running:0')
.mockResolvedValueOnce(null);
await listTasks('/project');
expect(mockHeader).toHaveBeenCalledWith('[running] running-task');
expect(mockInfo).toHaveBeenCalledWith('Running task is read-only.');
expect(mockShowDiffAndPromptActionForTask).not.toHaveBeenCalled();
});
it('completed タスクで branch が無い場合はアクションに進まない', async () => {
mockListAllTaskItems.mockReturnValue([completedTaskWithoutBranch]);
mockSelectOption
.mockResolvedValueOnce('completed:0')
.mockResolvedValueOnce(null);
await listTasks('/project');
expect(mockInfo).toHaveBeenCalledWith('Branch is missing for completed task: completed-without-branch');
expect(mockShowDiffAndPromptActionForTask).not.toHaveBeenCalled();
});
it('completed merge 成功時は tasks.yaml から completed レコードを削除する', async () => {
mockListAllTaskItems.mockReturnValue([completedTaskWithBranch]);
mockShowDiffAndPromptActionForTask.mockResolvedValueOnce('merge');
mockMergeBranch.mockReturnValue(true);
mockSelectOption
.mockResolvedValueOnce('completed:0')
.mockResolvedValueOnce(null);
await listTasks('/project');
expect(mockMergeBranch).toHaveBeenCalledWith('/project', completedTaskWithBranch);
expect(mockDeleteCompletedRecord).toHaveBeenCalledWith('completed-task');
});
it('completed delete 選択時は deleteCompletedTask を呼ぶ', async () => {
mockListAllTaskItems.mockReturnValue([completedTaskWithBranch]);
mockShowDiffAndPromptActionForTask.mockResolvedValueOnce('delete');
mockSelectOption
.mockResolvedValueOnce('completed:0')
.mockResolvedValueOnce(null);
await listTasks('/project');
expect(mockDeleteCompletedTask).toHaveBeenCalledWith(completedTaskWithBranch);
expect(mockDeleteCompletedRecord).not.toHaveBeenCalled();
});
});

View File

@ -57,8 +57,10 @@ vi.mock('@opencode-ai/sdk/v2', () => ({
})); }));
describe('OpenCodeClient stream cleanup', () => { describe('OpenCodeClient stream cleanup', () => {
beforeEach(() => { beforeEach(async () => {
vi.clearAllMocks(); vi.clearAllMocks();
const { resetSharedServer } = await import('../infra/opencode/client.js');
resetSharedServer();
}); });
it('should close SSE stream when session.idle is received', async () => { it('should close SSE stream when session.idle is received', async () => {
@ -445,52 +447,6 @@ describe('OpenCodeClient stream cleanup', () => {
); );
}); });
it('should configure allow permissions for edit mode', async () => {
const { OpenCodeClient } = await import('../infra/opencode/client.js');
const stream = new MockEventStream([
{
type: 'message.updated',
properties: {
info: {
sessionID: 'session-perm',
role: 'assistant',
time: { created: Date.now(), completed: Date.now() + 1 },
},
},
},
]);
const promptAsync = vi.fn().mockResolvedValue(undefined);
const sessionCreate = vi.fn().mockResolvedValue({ data: { id: 'session-perm' } });
const disposeInstance = vi.fn().mockResolvedValue({ data: {} });
const subscribe = vi.fn().mockResolvedValue({ stream });
createOpencodeMock.mockResolvedValue({
client: {
instance: { dispose: disposeInstance },
session: { create: sessionCreate, promptAsync },
event: { subscribe },
permission: { reply: vi.fn() },
},
server: { close: vi.fn() },
});
const client = new OpenCodeClient();
await client.call('coder', 'hello', {
cwd: '/tmp',
model: 'opencode/big-pickle',
permissionMode: 'edit',
});
const createCallArgs = createOpencodeMock.mock.calls[0]?.[0] as { config?: Record<string, unknown> };
const permission = createCallArgs.config?.permission as Record<string, string>;
expect(permission.read).toBe('allow');
expect(permission.edit).toBe('allow');
expect(permission.write).toBe('allow');
expect(permission.bash).toBe('allow');
expect(permission.question).toBe('deny');
});
it('should pass permission ruleset to session.create', async () => { it('should pass permission ruleset to session.create', async () => {
const { OpenCodeClient } = await import('../infra/opencode/client.js'); const { OpenCodeClient } = await import('../infra/opencode/client.js');
const stream = new MockEventStream([ const stream = new MockEventStream([
@ -578,4 +534,85 @@ describe('OpenCodeClient stream cleanup', () => {
expect(result.status).toBe('error'); expect(result.status).toBe('error');
expect(result.content).toContain('permission reply timed out'); expect(result.content).toContain('permission reply timed out');
}); });
it('should reuse shared server for parallel calls with same config', async () => {
const { OpenCodeClient, resetSharedServer } = await import('../infra/opencode/client.js');
resetSharedServer();
let callCount = 0;
const sessionCreate = vi.fn().mockImplementation(() => {
callCount += 1;
return Promise.resolve({ data: { id: `session-${callCount}` } });
});
const promptAsync = vi.fn().mockResolvedValue(undefined);
const disposeInstance = vi.fn().mockResolvedValue({ data: {} });
const serverClose = vi.fn();
createOpencodeMock.mockResolvedValue({
client: {
instance: { dispose: disposeInstance },
session: { create: sessionCreate, promptAsync },
event: { subscribe: vi.fn().mockImplementation(() => {
const events = [{ type: 'session.idle', properties: { sessionID: `session-${callCount}` } }];
return Promise.resolve({ stream: new MockEventStream(events) });
}) },
permission: { reply: vi.fn() },
},
server: { close: serverClose },
});
const client = new OpenCodeClient();
const [result1, result2, result3] = await Promise.all([
client.call('coder', 'task1', { cwd: '/tmp', model: 'opencode/big-pickle' }),
client.call('coder', 'task2', { cwd: '/tmp', model: 'opencode/big-pickle' }),
client.call('coder', 'task3', { cwd: '/tmp', model: 'opencode/big-pickle' }),
]);
expect(createOpencodeMock).toHaveBeenCalledTimes(1);
expect(sessionCreate).toHaveBeenCalledTimes(3);
expect(result1.status).toBe('done');
expect(result2.status).toBe('done');
expect(result3.status).toBe('done');
expect(serverClose).not.toHaveBeenCalled();
});
it('should create new server when model changes', async () => {
const { OpenCodeClient, resetSharedServer } = await import('../infra/opencode/client.js');
resetSharedServer();
const sessionCreate = vi.fn().mockResolvedValue({ data: { id: 'session-1' } });
const promptAsync = vi.fn().mockResolvedValue(undefined);
const disposeInstance = vi.fn().mockResolvedValue({ data: {} });
const serverClose1 = vi.fn();
const serverClose2 = vi.fn();
createOpencodeMock.mockResolvedValueOnce({
client: {
instance: { dispose: disposeInstance },
session: { create: sessionCreate, promptAsync },
event: { subscribe: vi.fn().mockResolvedValue({ stream: new MockEventStream([{ type: 'session.idle', properties: { sessionID: 'session-1' } }]) }) },
permission: { reply: vi.fn() },
},
server: { close: serverClose1 },
}).mockResolvedValueOnce({
client: {
instance: { dispose: disposeInstance },
session: { create: sessionCreate, promptAsync },
event: { subscribe: vi.fn().mockResolvedValue({ stream: new MockEventStream([{ type: 'session.idle', properties: { sessionID: 'session-2' } }]) }) },
permission: { reply: vi.fn() },
},
server: { close: serverClose2 },
});
const client = new OpenCodeClient();
const result1 = await client.call('coder', 'task1', { cwd: '/tmp', model: 'opencode/model-a' });
const result2 = await client.call('coder', 'task2', { cwd: '/tmp', model: 'opencode/model-b' });
expect(createOpencodeMock).toHaveBeenCalledTimes(2);
expect(serverClose1).toHaveBeenCalled();
expect(result1.status).toBe('done');
expect(result2.status).toBe('done');
});
}); });

View File

@ -0,0 +1,210 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const {
getProviderMock,
loadProjectConfigMock,
loadGlobalConfigMock,
loadCustomAgentsMock,
loadAgentPromptMock,
loadTemplateMock,
providerSetupMock,
providerCallMock,
} = vi.hoisted(() => {
const providerCall = vi.fn();
const providerSetup = vi.fn(() => ({ call: providerCall }));
return {
getProviderMock: vi.fn(() => ({ setup: providerSetup })),
loadProjectConfigMock: vi.fn(),
loadGlobalConfigMock: vi.fn(),
loadCustomAgentsMock: vi.fn(),
loadAgentPromptMock: vi.fn(),
loadTemplateMock: vi.fn(),
providerSetupMock: providerSetup,
providerCallMock: providerCall,
};
});
vi.mock('../infra/providers/index.js', () => ({
getProvider: getProviderMock,
}));
vi.mock('../infra/config/index.js', () => ({
loadProjectConfig: loadProjectConfigMock,
loadGlobalConfig: loadGlobalConfigMock,
loadCustomAgents: loadCustomAgentsMock,
loadAgentPrompt: loadAgentPromptMock,
}));
vi.mock('../shared/prompts/index.js', () => ({
loadTemplate: loadTemplateMock,
}));
import { runAgent } from '../agents/runner.js';
describe('option resolution order', () => {
beforeEach(() => {
vi.clearAllMocks();
providerCallMock.mockResolvedValue({ content: 'ok' });
loadProjectConfigMock.mockReturnValue({});
loadGlobalConfigMock.mockReturnValue({});
loadCustomAgentsMock.mockReturnValue(new Map());
loadAgentPromptMock.mockReturnValue('prompt');
loadTemplateMock.mockReturnValue('template');
});
it('should resolve provider in order: CLI > Local > Piece(step) > Global', async () => {
// Given
loadProjectConfigMock.mockReturnValue({ provider: 'opencode' });
loadGlobalConfigMock.mockReturnValue({ provider: 'mock' });
// When: CLI provider が指定される
await runAgent(undefined, 'task', {
cwd: '/repo',
provider: 'codex',
stepProvider: 'claude',
});
// Then
expect(getProviderMock).toHaveBeenLastCalledWith('codex');
// When: CLI 指定なしLocal が有効)
await runAgent(undefined, 'task', {
cwd: '/repo',
stepProvider: 'claude',
});
// Then
expect(getProviderMock).toHaveBeenLastCalledWith('opencode');
// When: Local なしPiece が有効)
loadProjectConfigMock.mockReturnValue({});
await runAgent(undefined, 'task', {
cwd: '/repo',
stepProvider: 'claude',
});
// Then
expect(getProviderMock).toHaveBeenLastCalledWith('claude');
// When: Piece なしGlobal が有効)
await runAgent(undefined, 'task', { cwd: '/repo' });
// Then
expect(getProviderMock).toHaveBeenLastCalledWith('mock');
});
it('should resolve model in order: CLI > Piece(step) > Global(matching provider)', async () => {
// Given
loadProjectConfigMock.mockReturnValue({ provider: 'claude' });
loadGlobalConfigMock.mockReturnValue({ provider: 'claude', model: 'global-model' });
// When: CLI model あり
await runAgent(undefined, 'task', {
cwd: '/repo',
model: 'cli-model',
stepModel: 'step-model',
});
// Then
expect(providerCallMock).toHaveBeenLastCalledWith(
'task',
expect.objectContaining({ model: 'cli-model' }),
);
// When: CLI model なし
await runAgent(undefined, 'task', {
cwd: '/repo',
stepModel: 'step-model',
});
// Then
expect(providerCallMock).toHaveBeenLastCalledWith(
'task',
expect.objectContaining({ model: 'step-model' }),
);
// When: stepModel なし
await runAgent(undefined, 'task', { cwd: '/repo' });
// Then
expect(providerCallMock).toHaveBeenLastCalledWith(
'task',
expect.objectContaining({ model: 'global-model' }),
);
});
it('should ignore global model when global provider does not match resolved provider', async () => {
// Given
loadProjectConfigMock.mockReturnValue({ provider: 'codex' });
loadGlobalConfigMock.mockReturnValue({ provider: 'claude', model: 'global-model' });
// When
await runAgent(undefined, 'task', { cwd: '/repo' });
// Then
expect(providerCallMock).toHaveBeenLastCalledWith(
'task',
expect.objectContaining({ model: undefined }),
);
});
it('should use providerOptions from piece(step) only', async () => {
// Given
const stepProviderOptions = {
claude: {
sandbox: {
allowUnsandboxedCommands: false,
},
},
};
loadProjectConfigMock.mockReturnValue({
provider: 'claude',
provider_options: {
claude: { sandbox: { allow_unsandboxed_commands: true } },
},
});
loadGlobalConfigMock.mockReturnValue({
provider: 'claude',
providerOptions: {
claude: { sandbox: { allowUnsandboxedCommands: true } },
},
});
// When
await runAgent(undefined, 'task', {
cwd: '/repo',
provider: 'claude',
providerOptions: stepProviderOptions,
});
// Then
expect(providerCallMock).toHaveBeenLastCalledWith(
'task',
expect.objectContaining({ providerOptions: stepProviderOptions }),
);
});
it('should use custom agent provider/model when higher-priority values are absent', async () => {
// Given
const customAgents = new Map([
['custom', { name: 'custom', prompt: 'agent prompt', provider: 'opencode', model: 'agent-model' }],
]);
loadCustomAgentsMock.mockReturnValue(customAgents);
// When
await runAgent('custom', 'task', { cwd: '/repo' });
// Then
expect(getProviderMock).toHaveBeenLastCalledWith('opencode');
expect(providerCallMock).toHaveBeenLastCalledWith(
'task',
expect.objectContaining({ model: 'agent-model' }),
);
expect(providerSetupMock).toHaveBeenLastCalledWith(
expect.objectContaining({ systemPrompt: 'prompt' }),
);
});
});

View File

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

View File

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

View File

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

View File

@ -4,6 +4,25 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'; import { describe, it, expect, vi, beforeEach } from 'vitest';
const {
mockAddTask,
mockCompleteTask,
mockFailTask,
mockExecuteTask,
} = vi.hoisted(() => ({
mockAddTask: vi.fn(() => ({
name: 'test-task',
content: 'test task',
filePath: '/project/.takt/tasks.yaml',
createdAt: '2026-02-14T00:00:00.000Z',
status: 'pending',
data: { task: 'test task' },
})),
mockCompleteTask: vi.fn(),
mockFailTask: vi.fn(),
mockExecuteTask: vi.fn(),
}));
vi.mock('../shared/prompt/index.js', () => ({ vi.mock('../shared/prompt/index.js', () => ({
confirm: vi.fn(), confirm: vi.fn(),
})); }));
@ -13,9 +32,6 @@ vi.mock('../infra/config/index.js', () => ({
listPieces: vi.fn(() => ['default']), listPieces: vi.fn(() => ['default']),
listPieceEntries: vi.fn(() => []), listPieceEntries: vi.fn(() => []),
isPiecePath: vi.fn(() => false), isPiecePath: vi.fn(() => false),
loadAllPiecesWithSources: vi.fn(() => new Map()),
getPieceCategories: vi.fn(() => null),
buildCategorizedPieces: vi.fn(),
loadGlobalConfig: vi.fn(() => ({})), loadGlobalConfig: vi.fn(() => ({})),
})); }));
@ -24,6 +40,11 @@ vi.mock('../infra/task/index.js', () => ({
autoCommitAndPush: vi.fn(), autoCommitAndPush: vi.fn(),
summarizeTaskName: vi.fn(), summarizeTaskName: vi.fn(),
getCurrentBranch: vi.fn(() => 'main'), getCurrentBranch: vi.fn(() => 'main'),
TaskRunner: vi.fn(() => ({
addTask: (...args: unknown[]) => mockAddTask(...args),
completeTask: (...args: unknown[]) => mockCompleteTask(...args),
failTask: (...args: unknown[]) => mockFailTask(...args),
})),
})); }));
vi.mock('../shared/ui/index.js', () => ({ vi.mock('../shared/ui/index.js', () => ({
@ -53,39 +74,30 @@ vi.mock('../infra/github/index.js', () => ({
})); }));
vi.mock('../features/tasks/execute/taskExecution.js', () => ({ vi.mock('../features/tasks/execute/taskExecution.js', () => ({
executeTask: vi.fn(), executeTask: (...args: unknown[]) => mockExecuteTask(...args),
})); }));
vi.mock('../features/pieceSelection/index.js', () => ({ vi.mock('../features/pieceSelection/index.js', () => ({
warnMissingPieces: vi.fn(), warnMissingPieces: vi.fn(),
selectPieceFromCategorizedPieces: vi.fn(), selectPieceFromCategorizedPieces: vi.fn(),
selectPieceFromEntries: vi.fn(), selectPieceFromEntries: vi.fn(),
selectPiece: vi.fn(),
})); }));
import { confirm } from '../shared/prompt/index.js'; import { confirm } from '../shared/prompt/index.js';
import {
getCurrentPiece,
loadAllPiecesWithSources,
getPieceCategories,
buildCategorizedPieces,
} from '../infra/config/index.js';
import { createSharedClone, autoCommitAndPush, summarizeTaskName } from '../infra/task/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'; import { selectAndExecuteTask, determinePiece } from '../features/tasks/execute/selectAndExecute.js';
const mockConfirm = vi.mocked(confirm); 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 mockCreateSharedClone = vi.mocked(createSharedClone); const mockCreateSharedClone = vi.mocked(createSharedClone);
const mockAutoCommitAndPush = vi.mocked(autoCommitAndPush); const mockAutoCommitAndPush = vi.mocked(autoCommitAndPush);
const mockSummarizeTaskName = vi.mocked(summarizeTaskName); const mockSummarizeTaskName = vi.mocked(summarizeTaskName);
const mockWarnMissingPieces = vi.mocked(warnMissingPieces); const mockSelectPiece = vi.mocked(selectPiece);
const mockSelectPieceFromCategorizedPieces = vi.mocked(selectPieceFromCategorizedPieces);
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
mockExecuteTask.mockResolvedValue(true);
}); });
describe('resolveAutoPr default in selectAndExecuteTask', () => { describe('resolveAutoPr default in selectAndExecuteTask', () => {
@ -98,10 +110,6 @@ describe('resolveAutoPr default in selectAndExecuteTask', () => {
branch: 'takt/test-task', branch: 'takt/test-task',
}); });
const { executeTask } = await import(
'../features/tasks/execute/taskExecution.js'
);
vi.mocked(executeTask).mockResolvedValue(true);
mockAutoCommitAndPush.mockReturnValue({ mockAutoCommitAndPush.mockReturnValue({
success: false, success: false,
message: 'no changes', message: 'no changes',
@ -121,44 +129,86 @@ describe('resolveAutoPr default in selectAndExecuteTask', () => {
expect(autoPrCall![1]).toBe(true); expect(autoPrCall![1]).toBe(true);
}); });
it('should warn only user-origin missing pieces during interactive selection', async () => { it('should call selectPiece when no override is provided', async () => {
// Given: category selection is enabled and both builtin/user missing pieces exist mockSelectPiece.mockResolvedValue('selected-piece');
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');
// When
const selected = await determinePiece('/project'); const selected = await determinePiece('/project');
// Then expect(selected).toBe('selected-piece');
expect(selected).toBe('default'); expect(mockSelectPiece).toHaveBeenCalledWith('/project');
expect(mockWarnMissingPieces).toHaveBeenCalledWith([ });
{ categoryPath: ['Custom'], pieceName: 'my-missing', source: 'user' },
]); it('should fail task record when executeTask throws', async () => {
mockConfirm.mockResolvedValue(true);
mockSummarizeTaskName.mockResolvedValue('test-task');
mockCreateSharedClone.mockReturnValue({
path: '/project/../clone',
branch: 'takt/test-task',
});
mockExecuteTask.mockRejectedValue(new Error('boom'));
await expect(selectAndExecuteTask('/project', 'test task', {
piece: 'default',
createWorktree: true,
})).rejects.toThrow('boom');
expect(mockAddTask).toHaveBeenCalledTimes(1);
expect(mockFailTask).toHaveBeenCalledTimes(1);
expect(mockCompleteTask).not.toHaveBeenCalled();
});
it('should record task and complete when executeTask returns true', async () => {
mockConfirm.mockResolvedValue(true);
mockSummarizeTaskName.mockResolvedValue('test-task');
mockCreateSharedClone.mockReturnValue({
path: '/project/../clone',
branch: 'takt/test-task',
});
mockExecuteTask.mockResolvedValue(true);
await selectAndExecuteTask('/project', 'test task', {
piece: 'default',
createWorktree: true,
});
expect(mockAddTask).toHaveBeenCalledWith('test task', expect.objectContaining({
piece: 'default',
worktree: true,
branch: 'takt/test-task',
worktree_path: '/project/../clone',
auto_pr: true,
}));
expect(mockCompleteTask).toHaveBeenCalledTimes(1);
expect(mockFailTask).not.toHaveBeenCalled();
});
it('should record task and fail when executeTask returns false', async () => {
mockConfirm.mockResolvedValue(false);
mockSummarizeTaskName.mockResolvedValue('test-task');
mockCreateSharedClone.mockReturnValue({
path: '/project/../clone',
branch: 'takt/test-task',
});
mockExecuteTask.mockResolvedValue(false);
const processExitSpy = vi.spyOn(process, 'exit').mockImplementation((() => {
throw new Error('process exit');
}) as (code?: string | number | null | undefined) => never);
await expect(selectAndExecuteTask('/project', 'test task', {
piece: 'default',
createWorktree: true,
})).rejects.toThrow('process exit');
expect(mockAddTask).toHaveBeenCalledWith('test task', expect.objectContaining({
piece: 'default',
worktree: true,
branch: 'takt/test-task',
worktree_path: '/project/../clone',
auto_pr: false,
}));
expect(mockFailTask).toHaveBeenCalledTimes(1);
expect(mockCompleteTask).not.toHaveBeenCalled();
processExitSpy.mockRestore();
}); });
}); });

View File

@ -5,19 +5,13 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'; import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('../infra/config/index.js', () => ({ 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), loadPiece: vi.fn(() => null),
getCurrentPiece: vi.fn(() => 'default'), getCurrentPiece: vi.fn(() => 'default'),
setCurrentPiece: vi.fn(), setCurrentPiece: vi.fn(),
})); }));
vi.mock('../features/pieceSelection/index.js', () => ({ vi.mock('../features/pieceSelection/index.js', () => ({
warnMissingPieces: vi.fn(), selectPiece: vi.fn(),
selectPieceFromCategorizedPieces: vi.fn(),
selectPieceFromEntries: vi.fn(),
})); }));
vi.mock('../shared/ui/index.js', () => ({ vi.mock('../shared/ui/index.js', () => ({
@ -26,65 +20,41 @@ vi.mock('../shared/ui/index.js', () => ({
error: vi.fn(), error: vi.fn(),
})); }));
import { import { getCurrentPiece, loadPiece, setCurrentPiece } from '../infra/config/index.js';
loadAllPiecesWithSources, import { selectPiece } from '../features/pieceSelection/index.js';
getPieceCategories,
buildCategorizedPieces,
} from '../infra/config/index.js';
import {
warnMissingPieces,
selectPieceFromCategorizedPieces,
} from '../features/pieceSelection/index.js';
import { switchPiece } from '../features/config/switchPiece.js'; import { switchPiece } from '../features/config/switchPiece.js';
const mockLoadAllPiecesWithSources = vi.mocked(loadAllPiecesWithSources); const mockGetCurrentPiece = vi.mocked(getCurrentPiece);
const mockGetPieceCategories = vi.mocked(getPieceCategories); const mockLoadPiece = vi.mocked(loadPiece);
const mockBuildCategorizedPieces = vi.mocked(buildCategorizedPieces); const mockSetCurrentPiece = vi.mocked(setCurrentPiece);
const mockWarnMissingPieces = vi.mocked(warnMissingPieces); const mockSelectPiece = vi.mocked(selectPiece);
const mockSelectPieceFromCategorizedPieces = vi.mocked(selectPieceFromCategorizedPieces);
describe('switchPiece', () => { describe('switchPiece', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
}); });
it('should warn only user-origin missing pieces during interactive switch', async () => { it('should call selectPiece with fallbackToDefault: false', async () => {
// Given mockSelectPiece.mockResolvedValue(null);
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);
// When
const switched = await switchPiece('/project'); const switched = await switchPiece('/project');
// Then
expect(switched).toBe(false); expect(switched).toBe(false);
expect(mockWarnMissingPieces).toHaveBeenCalledWith([ expect(mockSelectPiece).toHaveBeenCalledWith('/project', { fallbackToDefault: false });
{ categoryPath: ['Custom'], pieceName: 'my-missing', source: 'user' }, });
]);
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');
}); });
}); });

View File

@ -215,7 +215,7 @@ describe('TaskRunner (tasks.yaml)', () => {
expect(() => runner.listTasks()).toThrow(/ENOENT|no such file/i); expect(() => runner.listTasks()).toThrow(/ENOENT|no such file/i);
}); });
it('should remove completed task record from tasks.yaml', () => { it('should keep completed task record in tasks.yaml', () => {
runner.addTask('Task A'); runner.addTask('Task A');
const task = runner.claimNextTasks(1)[0]!; const task = runner.claimNextTasks(1)[0]!;
@ -229,10 +229,11 @@ describe('TaskRunner (tasks.yaml)', () => {
}); });
const file = loadTasksFile(testDir); const file = loadTasksFile(testDir);
expect(file.tasks).toHaveLength(0); expect(file.tasks).toHaveLength(1);
expect(file.tasks[0]?.status).toBe('completed');
}); });
it('should remove only the completed task when multiple tasks exist', () => { it('should update only target task to completed when multiple tasks exist', () => {
runner.addTask('Task A'); runner.addTask('Task A');
runner.addTask('Task B'); runner.addTask('Task B');
const task = runner.claimNextTasks(1)[0]!; const task = runner.claimNextTasks(1)[0]!;
@ -247,9 +248,11 @@ describe('TaskRunner (tasks.yaml)', () => {
}); });
const file = loadTasksFile(testDir); const file = loadTasksFile(testDir);
expect(file.tasks).toHaveLength(1); expect(file.tasks).toHaveLength(2);
expect(file.tasks[0]?.name).toContain('task-b'); expect(file.tasks[0]?.name).toContain('task-a');
expect(file.tasks[0]?.status).toBe('pending'); expect(file.tasks[0]?.status).toBe('completed');
expect(file.tasks[1]?.name).toContain('task-b');
expect(file.tasks[1]?.status).toBe('pending');
}); });
it('should mark claimed task as failed with failure detail', () => { it('should mark claimed task as failed with failure detail', () => {
@ -274,6 +277,29 @@ describe('TaskRunner (tasks.yaml)', () => {
expect(failed[0]?.failure?.last_message).toBe('last message'); expect(failed[0]?.failure?.last_message).toBe('last message');
}); });
it('should mark pending task as failed with started_at and branch', () => {
const task = runner.addTask('Task C', { branch: 'takt/task-c' });
const startedAt = new Date().toISOString();
const completedAt = new Date().toISOString();
runner.failTask({
task,
success: false,
response: 'Boom',
executionLog: [],
startedAt,
completedAt,
branch: 'takt/task-c-updated',
});
const file = loadTasksFile(testDir);
const failed = file.tasks[0];
expect(failed?.status).toBe('failed');
expect(failed?.started_at).toBe(startedAt);
expect(failed?.completed_at).toBe(completedAt);
expect(failed?.branch).toBe('takt/task-c-updated');
});
it('should requeue failed task to pending with retry metadata', () => { it('should requeue failed task to pending with retry metadata', () => {
runner.addTask('Task A'); runner.addTask('Task A');
const task = runner.claimNextTasks(1)[0]!; const task = runner.claimNextTasks(1)[0]!;

View File

@ -21,9 +21,14 @@ vi.mock('../shared/utils/index.js', async (importOriginal) => ({
}), }),
})); }));
const mockDeleteBranch = vi.fn();
vi.mock('../features/tasks/list/taskActions.js', () => ({
deleteBranch: (...args: unknown[]) => mockDeleteBranch(...args),
}));
import { confirm } from '../shared/prompt/index.js'; import { confirm } from '../shared/prompt/index.js';
import { success, error as logError } from '../shared/ui/index.js'; import { success, error as logError } from '../shared/ui/index.js';
import { deletePendingTask, deleteFailedTask } from '../features/tasks/list/taskDeleteActions.js'; import { deletePendingTask, deleteFailedTask, deleteCompletedTask } from '../features/tasks/list/taskDeleteActions.js';
import type { TaskListItem } from '../infra/task/types.js'; import type { TaskListItem } from '../infra/task/types.js';
const mockConfirm = vi.mocked(confirm); const mockConfirm = vi.mocked(confirm);
@ -54,6 +59,16 @@ function setupTasksFile(projectDir: string): string {
completed_at: '2025-01-15T00:02:00.000Z', completed_at: '2025-01-15T00:02:00.000Z',
failure: { error: 'boom' }, failure: { error: 'boom' },
}, },
{
name: 'completed-task',
status: 'completed',
content: 'completed',
branch: 'takt/completed-task',
worktree_path: '/tmp/takt/completed-task',
created_at: '2025-01-15T00:00:00.000Z',
started_at: '2025-01-15T00:01:00.000Z',
completed_at: '2025-01-15T00:02:00.000Z',
},
], ],
}), 'utf-8'); }), 'utf-8');
return tasksFile; return tasksFile;
@ -61,6 +76,7 @@ function setupTasksFile(projectDir: string): string {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
mockDeleteBranch.mockReturnValue(true);
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'takt-test-delete-')); tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'takt-test-delete-'));
}); });
@ -107,6 +123,50 @@ describe('taskDeleteActions', () => {
expect(mockSuccess).toHaveBeenCalledWith('Deleted failed task: failed-task'); expect(mockSuccess).toHaveBeenCalledWith('Deleted failed task: failed-task');
}); });
it('should cleanup branch before deleting failed task when branch exists', async () => {
const tasksFile = setupTasksFile(tmpDir);
const task: TaskListItem = {
kind: 'failed',
name: 'failed-task',
createdAt: '2025-01-15T12:34:56',
filePath: tasksFile,
content: 'failed',
branch: 'takt/failed-task',
worktreePath: '/tmp/takt/failed-task',
};
mockConfirm.mockResolvedValue(true);
const result = await deleteFailedTask(task);
expect(result).toBe(true);
expect(mockDeleteBranch).toHaveBeenCalledWith(tmpDir, task);
const raw = fs.readFileSync(tasksFile, 'utf-8');
expect(raw).not.toContain('failed-task');
expect(mockSuccess).toHaveBeenCalledWith('Deleted failed task: failed-task');
});
it('should keep failed task record when branch cleanup fails', async () => {
const tasksFile = setupTasksFile(tmpDir);
const task: TaskListItem = {
kind: 'failed',
name: 'failed-task',
createdAt: '2025-01-15T12:34:56',
filePath: tasksFile,
content: 'failed',
branch: 'takt/failed-task',
worktreePath: '/tmp/takt/failed-task',
};
mockConfirm.mockResolvedValue(true);
mockDeleteBranch.mockReturnValue(false);
const result = await deleteFailedTask(task);
expect(result).toBe(false);
expect(mockDeleteBranch).toHaveBeenCalledWith(tmpDir, task);
const raw = fs.readFileSync(tasksFile, 'utf-8');
expect(raw).toContain('failed-task');
});
it('should return false when target task is missing', async () => { it('should return false when target task is missing', async () => {
const tasksFile = setupTasksFile(tmpDir); const tasksFile = setupTasksFile(tmpDir);
const task: TaskListItem = { const task: TaskListItem = {
@ -123,4 +183,26 @@ describe('taskDeleteActions', () => {
expect(result).toBe(false); expect(result).toBe(false);
expect(mockLogError).toHaveBeenCalled(); expect(mockLogError).toHaveBeenCalled();
}); });
it('should delete completed task and cleanup worktree when confirmed', async () => {
const tasksFile = setupTasksFile(tmpDir);
const task: TaskListItem = {
kind: 'completed',
name: 'completed-task',
createdAt: '2025-01-15T12:34:56',
filePath: tasksFile,
content: 'completed',
branch: 'takt/completed-task',
worktreePath: '/tmp/takt/completed-task',
};
mockConfirm.mockResolvedValue(true);
const result = await deleteCompletedTask(task);
expect(result).toBe(true);
expect(mockDeleteBranch).toHaveBeenCalledWith(tmpDir, task);
const raw = fs.readFileSync(tasksFile, 'utf-8');
expect(raw).not.toContain('completed-task');
expect(mockSuccess).toHaveBeenCalledWith('Deleted completed task: completed-task');
});
}); });

View File

@ -120,6 +120,7 @@ describe('resolveTaskExecution', () => {
execCwd: '/project', execCwd: '/project',
execPiece: 'default', execPiece: 'default',
isWorktree: false, isWorktree: false,
autoPr: false,
}); });
expect(mockSummarizeTaskName).not.toHaveBeenCalled(); expect(mockSummarizeTaskName).not.toHaveBeenCalled();
expect(mockCreateSharedClone).not.toHaveBeenCalled(); expect(mockCreateSharedClone).not.toHaveBeenCalled();
@ -177,7 +178,9 @@ describe('resolveTaskExecution', () => {
execCwd: '/project/../20260128T0504-add-auth', execCwd: '/project/../20260128T0504-add-auth',
execPiece: 'default', execPiece: 'default',
isWorktree: true, isWorktree: true,
autoPr: false,
branch: 'takt/20260128T0504-add-auth', branch: 'takt/20260128T0504-add-auth',
worktreePath: '/project/../20260128T0504-add-auth',
baseBranch: 'main', baseBranch: 'main',
}); });
}); });
@ -372,7 +375,7 @@ describe('resolveTaskExecution', () => {
expect(result.autoPr).toBe(true); expect(result.autoPr).toBe(true);
}); });
it('should return undefined autoPr when neither task nor config specifies', async () => { it('should return false autoPr when neither task nor config specifies', async () => {
// Given: Neither task nor config has autoPr // Given: Neither task nor config has autoPr
mockLoadGlobalConfig.mockReturnValue({ mockLoadGlobalConfig.mockReturnValue({
language: 'en', language: 'en',
@ -393,7 +396,7 @@ describe('resolveTaskExecution', () => {
const result = await resolveTaskExecution(task, '/project', 'default'); const result = await resolveTaskExecution(task, '/project', 'default');
// Then // Then
expect(result.autoPr).toBeUndefined(); expect(result.autoPr).toBe(false);
}); });
it('should prioritize task YAML auto_pr over global config', async () => { it('should prioritize task YAML auto_pr over global config', async () => {

View File

@ -0,0 +1,157 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
const {
mockAddTask,
mockCompleteTask,
mockFailTask,
mockExecuteTask,
mockRunInstructMode,
mockDispatchConversationAction,
mockSelectPiece,
} = vi.hoisted(() => ({
mockAddTask: vi.fn(() => ({
name: 'instruction-task',
content: 'instruction',
filePath: '/project/.takt/tasks.yaml',
createdAt: '2026-02-14T00:00:00.000Z',
status: 'pending',
data: { task: 'instruction' },
})),
mockCompleteTask: vi.fn(),
mockFailTask: vi.fn(),
mockExecuteTask: vi.fn(),
mockRunInstructMode: vi.fn(),
mockDispatchConversationAction: vi.fn(),
mockSelectPiece: vi.fn(),
}));
vi.mock('../infra/task/index.js', () => ({
createTempCloneForBranch: vi.fn(() => ({ path: '/tmp/clone', branch: 'takt/sample' })),
removeClone: vi.fn(),
removeCloneMeta: vi.fn(),
detectDefaultBranch: vi.fn(() => 'main'),
autoCommitAndPush: vi.fn(() => ({ success: false, message: 'no changes' })),
TaskRunner: class {
addTask(...args: unknown[]) {
return mockAddTask(...args);
}
completeTask(...args: unknown[]) {
return mockCompleteTask(...args);
}
failTask(...args: unknown[]) {
return mockFailTask(...args);
}
},
}));
vi.mock('../infra/config/index.js', () => ({
loadGlobalConfig: vi.fn(() => ({ interactivePreviewMovements: false })),
getPieceDescription: vi.fn(() => ({
name: 'default',
description: 'desc',
pieceStructure: [],
movementPreviews: [],
})),
}));
vi.mock('../features/tasks/execute/taskExecution.js', () => ({
executeTask: (...args: unknown[]) => mockExecuteTask(...args),
}));
vi.mock('../features/tasks/list/instructMode.js', () => ({
runInstructMode: (...args: unknown[]) => mockRunInstructMode(...args),
}));
vi.mock('../features/tasks/add/index.js', () => ({
saveTaskFile: vi.fn(),
}));
vi.mock('../features/pieceSelection/index.js', () => ({
selectPiece: (...args: unknown[]) => mockSelectPiece(...args),
}));
vi.mock('../features/interactive/actionDispatcher.js', () => ({
dispatchConversationAction: (...args: unknown[]) => mockDispatchConversationAction(...args),
}));
vi.mock('../shared/ui/index.js', () => ({
info: vi.fn(),
success: vi.fn(),
error: vi.fn(),
}));
vi.mock('../shared/utils/index.js', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
createLogger: () => ({
info: vi.fn(),
error: vi.fn(),
debug: vi.fn(),
}),
}));
import { instructBranch } from '../features/tasks/list/taskActions.js';
describe('instructBranch execute flow', () => {
beforeEach(() => {
vi.clearAllMocks();
mockSelectPiece.mockResolvedValue('default');
mockRunInstructMode.mockResolvedValue({ type: 'execute', task: '追加して' });
mockDispatchConversationAction.mockImplementation(async (_result, handlers) => handlers.execute({ task: '追加して' }));
});
it('should record addTask and completeTask on success', async () => {
mockExecuteTask.mockResolvedValue(true);
const result = await instructBranch('/project', {
kind: 'completed',
name: 'done-task',
createdAt: '2026-02-14T00:00:00.000Z',
filePath: '/project/.takt/tasks.yaml',
content: 'done',
branch: 'takt/done-task',
worktreePath: '/project/.takt/worktrees/done-task',
});
expect(result).toBe(true);
expect(mockAddTask).toHaveBeenCalledTimes(1);
expect(mockCompleteTask).toHaveBeenCalledTimes(1);
expect(mockFailTask).not.toHaveBeenCalled();
});
it('should record addTask and failTask on failure', async () => {
mockExecuteTask.mockResolvedValue(false);
const result = await instructBranch('/project', {
kind: 'completed',
name: 'done-task',
createdAt: '2026-02-14T00:00:00.000Z',
filePath: '/project/.takt/tasks.yaml',
content: 'done',
branch: 'takt/done-task',
worktreePath: '/project/.takt/worktrees/done-task',
});
expect(result).toBe(false);
expect(mockAddTask).toHaveBeenCalledTimes(1);
expect(mockFailTask).toHaveBeenCalledTimes(1);
expect(mockCompleteTask).not.toHaveBeenCalled();
});
it('should record failTask when executeTask throws', async () => {
mockExecuteTask.mockRejectedValue(new Error('crashed'));
await expect(instructBranch('/project', {
kind: 'completed',
name: 'done-task',
createdAt: '2026-02-14T00:00:00.000Z',
filePath: '/project/.takt/tasks.yaml',
content: 'done',
branch: 'takt/done-task',
worktreePath: '/project/.takt/worktrees/done-task',
})).rejects.toThrow('crashed');
expect(mockAddTask).toHaveBeenCalledTimes(1);
expect(mockFailTask).toHaveBeenCalledTimes(1);
expect(mockCompleteTask).not.toHaveBeenCalled();
});
});

View File

@ -3,7 +3,7 @@ import { formatTaskStatusLabel } from '../features/tasks/list/taskStatusLabel.js
import type { TaskListItem } from '../infra/task/types.js'; import type { TaskListItem } from '../infra/task/types.js';
describe('formatTaskStatusLabel', () => { describe('formatTaskStatusLabel', () => {
it("should format pending task as '[running] name'", () => { it("should format pending task as '[pending] name'", () => {
// Given: pending タスク // Given: pending タスク
const task: TaskListItem = { const task: TaskListItem = {
kind: 'pending', kind: 'pending',
@ -16,8 +16,8 @@ describe('formatTaskStatusLabel', () => {
// When: ステータスラベルを生成する // When: ステータスラベルを生成する
const result = formatTaskStatusLabel(task); const result = formatTaskStatusLabel(task);
// Then: pending は running 表示になる // Then: pending は pending 表示になる
expect(result).toBe('[running] implement test'); expect(result).toBe('[pending] implement test');
}); });
it("should format failed task as '[failed] name'", () => { it("should format failed task as '[failed] name'", () => {

View File

@ -29,14 +29,15 @@ export class AgentRunner {
agentConfig?: CustomAgentConfig, agentConfig?: CustomAgentConfig,
): ProviderType { ): ProviderType {
if (options?.provider) return options.provider; if (options?.provider) return options.provider;
if (agentConfig?.provider) return agentConfig.provider;
const projectConfig = loadProjectConfig(cwd); const projectConfig = loadProjectConfig(cwd);
if (projectConfig.provider) return projectConfig.provider; if (projectConfig.provider) return projectConfig.provider;
if (options?.stepProvider) return options.stepProvider;
if (agentConfig?.provider) return agentConfig.provider;
try { try {
const globalConfig = loadGlobalConfig(); const globalConfig = loadGlobalConfig();
if (globalConfig.provider) return globalConfig.provider; if (globalConfig.provider) return globalConfig.provider;
} catch { } catch (error) {
// Ignore missing global config; fallback below log.debug('Global config not available for provider resolution', { error });
} }
return 'claude'; return 'claude';
} }
@ -52,6 +53,7 @@ export class AgentRunner {
agentConfig?: CustomAgentConfig, agentConfig?: CustomAgentConfig,
): string | undefined { ): string | undefined {
if (options?.model) return options.model; if (options?.model) return options.model;
if (options?.stepModel) return options.stepModel;
if (agentConfig?.model) return agentConfig.model; if (agentConfig?.model) return agentConfig.model;
try { try {
const globalConfig = loadGlobalConfig(); const globalConfig = loadGlobalConfig();
@ -59,8 +61,8 @@ export class AgentRunner {
const globalProvider = globalConfig.provider ?? 'claude'; const globalProvider = globalConfig.provider ?? 'claude';
if (globalProvider === resolvedProvider) return globalConfig.model; if (globalProvider === resolvedProvider) return globalConfig.model;
} }
} catch { } catch (error) {
// Ignore missing global config log.debug('Global config not available for model resolution', { error });
} }
return undefined; return undefined;
} }

View File

@ -3,7 +3,7 @@
*/ */
import type { StreamCallback, PermissionHandler, AskUserQuestionHandler } from '../infra/claude/types.js'; 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 }; export type { StreamCallback };
@ -14,29 +14,19 @@ export interface RunAgentOptions {
sessionId?: string; sessionId?: string;
model?: string; model?: string;
provider?: 'claude' | 'codex' | 'opencode' | 'mock'; provider?: 'claude' | 'codex' | 'opencode' | 'mock';
/** Resolved path to persona prompt file */ stepModel?: string;
stepProvider?: 'claude' | 'codex' | 'opencode' | 'mock';
personaPath?: string; personaPath?: string;
/** Allowed tools for this agent run */
allowedTools?: string[]; allowedTools?: string[];
/** MCP servers for this agent run */
mcpServers?: Record<string, McpServerConfig>; mcpServers?: Record<string, McpServerConfig>;
/** Maximum number of agentic turns */
maxTurns?: number; maxTurns?: number;
/** Permission mode for tool execution (from piece step) */
permissionMode?: PermissionMode; permissionMode?: PermissionMode;
/** Provider-specific movement options */ providerOptions?: MovementProviderOptions;
providerOptions?: {
codex?: { networkAccess?: boolean };
opencode?: { networkAccess?: boolean };
};
onStream?: StreamCallback; onStream?: StreamCallback;
onPermissionRequest?: PermissionHandler; onPermissionRequest?: PermissionHandler;
onAskUserQuestion?: AskUserQuestionHandler; onAskUserQuestion?: AskUserQuestionHandler;
/** Bypass all permission checks (sacrifice-my-pc mode) */
bypassPermissions?: boolean; bypassPermissions?: boolean;
/** Language for template resolution */
language?: Language; language?: Language;
/** Piece meta information for system prompt template */
pieceMeta?: { pieceMeta?: {
pieceName: string; pieceName: string;
pieceDescription?: string; pieceDescription?: string;
@ -44,6 +34,5 @@ export interface RunAgentOptions {
movementsList: ReadonlyArray<{ name: string; description?: string }>; movementsList: ReadonlyArray<{ name: string; description?: string }>;
currentPosition: string; currentPosition: string;
}; };
/** JSON Schema for structured output */
outputSchema?: Record<string, unknown>; outputSchema?: Record<string, unknown>;
} }

View File

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

View File

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

View File

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

View File

@ -92,10 +92,24 @@ export interface OpenCodeProviderOptions {
networkAccess?: boolean; 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 */ /** Provider-specific movement options */
export interface MovementProviderOptions { export interface MovementProviderOptions {
codex?: CodexProviderOptions; codex?: CodexProviderOptions;
opencode?: OpenCodeProviderOptions; opencode?: OpenCodeProviderOptions;
claude?: ClaudeProviderOptions;
} }
/** Single movement in a piece */ /** Single movement in a piece */

View File

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

View File

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

View File

@ -10,6 +10,7 @@ export interface JudgeStatusOptions {
cwd: string; cwd: string;
movementName: string; movementName: string;
language?: Language; language?: Language;
interactive?: boolean;
} }
export interface JudgeStatusResult { export interface JudgeStatusResult {
@ -98,6 +99,14 @@ export async function judgeStatus(
return { ruleIndex: 0, method: 'auto_select' }; return { ruleIndex: 0, method: 'auto_select' };
} }
const interactiveEnabled = options.interactive === true;
const isValidRuleIndex = (index: number): boolean => {
if (index < 0 || index >= rules.length) return false;
const rule = rules[index];
return !(rule?.interactiveOnly && !interactiveEnabled);
};
const agentOptions = { const agentOptions = {
cwd: options.cwd, cwd: options.cwd,
maxTurns: 3, maxTurns: 3,
@ -115,7 +124,7 @@ export async function judgeStatus(
const stepNumber = structuredResponse.structuredOutput?.step; const stepNumber = structuredResponse.structuredOutput?.step;
if (typeof stepNumber === 'number' && Number.isInteger(stepNumber)) { if (typeof stepNumber === 'number' && Number.isInteger(stepNumber)) {
const ruleIndex = stepNumber - 1; const ruleIndex = stepNumber - 1;
if (ruleIndex >= 0 && ruleIndex < rules.length) { if (isValidRuleIndex(ruleIndex)) {
return { ruleIndex, method: 'structured_output' }; return { ruleIndex, method: 'structured_output' };
} }
} }
@ -126,16 +135,25 @@ export async function judgeStatus(
if (tagResponse.status === 'done') { if (tagResponse.status === 'done') {
const tagRuleIndex = detectRuleIndex(tagResponse.content, options.movementName); const tagRuleIndex = detectRuleIndex(tagResponse.content, options.movementName);
if (tagRuleIndex >= 0 && tagRuleIndex < rules.length) { if (isValidRuleIndex(tagRuleIndex)) {
return { ruleIndex: tagRuleIndex, method: 'phase3_tag' }; return { ruleIndex: tagRuleIndex, method: 'phase3_tag' };
} }
} }
// Stage 3: AI judge // Stage 3: AI judge
const conditions = rules.map((rule, index) => ({ index, text: rule.condition })); const conditions = rules
const fallbackIndex = await evaluateCondition(structuredInstruction, conditions, { cwd: options.cwd }); .map((rule, index) => ({ rule, index }))
if (fallbackIndex >= 0 && fallbackIndex < rules.length) { .filter(({ rule }) => interactiveEnabled || !rule.interactiveOnly)
return { ruleIndex: fallbackIndex, method: 'ai_judge' }; .map(({ index, rule }) => ({ index, text: rule.condition }));
if (conditions.length > 0) {
const fallbackIndex = await evaluateCondition(structuredInstruction, conditions, { cwd: options.cwd });
if (fallbackIndex >= 0 && fallbackIndex < conditions.length) {
const originalIndex = conditions[fallbackIndex]?.index;
if (originalIndex !== undefined) {
return { ruleIndex: originalIndex, method: 'ai_judge' };
}
}
} }
throw new Error(`Status not found for movement "${options.movementName}"`); throw new Error(`Status not found for movement "${options.movementName}"`);

View File

@ -35,8 +35,10 @@ export class OptionsBuilder {
cwd: this.getCwd(), cwd: this.getCwd(),
abortSignal: this.engineOptions.abortSignal, abortSignal: this.engineOptions.abortSignal,
personaPath: step.personaPath, personaPath: step.personaPath,
provider: resolved.provider, provider: this.engineOptions.provider,
model: resolved.model, model: this.engineOptions.model,
stepProvider: resolved.provider,
stepModel: resolved.model,
permissionMode: step.permissionMode, permissionMode: step.permissionMode,
providerOptions: step.providerOptions, providerOptions: step.providerOptions,
language: this.getLanguage(), language: this.getLanguage(),

View File

@ -36,6 +36,7 @@ function buildBaseContext(
if (reports.length > 0) { if (reports.length > 0) {
return { return {
language: ctx.language, language: ctx.language,
interactive: ctx.interactive,
reportContent: reports.join('\n\n---\n\n'), reportContent: reports.join('\n\n---\n\n'),
inputSource: 'report', inputSource: 'report',
}; };
@ -46,6 +47,7 @@ function buildBaseContext(
return { return {
language: ctx.language, language: ctx.language,
interactive: ctx.interactive,
lastResponse: ctx.lastResponse, lastResponse: ctx.lastResponse,
inputSource: 'response', inputSource: 'response',
}; };
@ -89,6 +91,7 @@ export async function runStatusJudgmentPhase(
cwd: ctx.cwd, cwd: ctx.cwd,
movementName: step.name, movementName: step.name,
language: ctx.language, language: ctx.language,
interactive: ctx.interactive,
}); });
const tag = `[${step.name.toUpperCase()}:${result.ruleIndex + 1}]`; const tag = `[${step.name.toUpperCase()}:${result.ruleIndex + 1}]`;
ctx.onPhaseComplete?.(step, 3, 'judge', tag, 'done'); ctx.onPhaseComplete?.(step, 3, 'judge', tag, 'done');

View File

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

View File

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

View File

@ -28,6 +28,7 @@ import {
type InteractiveModeResult, type InteractiveModeResult,
type InteractiveUIText, type InteractiveUIText,
type ConversationMessage, type ConversationMessage,
type PostSummaryAction,
resolveLanguage, resolveLanguage,
buildSummaryPrompt, buildSummaryPrompt,
selectPostSummaryAction, selectPostSummaryAction,
@ -171,6 +172,8 @@ export async function callAIWithRetry(
} }
} }
export type { PostSummaryAction } from './interactive.js';
/** Strategy for customizing conversation loop behavior */ /** Strategy for customizing conversation loop behavior */
export interface ConversationStrategy { export interface ConversationStrategy {
/** System prompt for AI calls */ /** System prompt for AI calls */
@ -181,6 +184,8 @@ export interface ConversationStrategy {
transformPrompt: (userMessage: string) => string; transformPrompt: (userMessage: string) => string;
/** Intro message displayed at start */ /** Intro message displayed at start */
introMessage: string; 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: '' }; return { action: 'cancel', task: '' };
} }
const task = summaryResult.content.trim(); 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) { if (selectedAction === 'continue' || selectedAction === null) {
info(ui.continuePrompt); info(ui.continuePrompt);
continue; continue;

View File

@ -169,21 +169,90 @@ export function buildSummaryPrompt(
export type PostSummaryAction = InteractiveModeAction | 'continue'; 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, task: string,
proposedLabel: string, proposedLabel: string,
ui: InteractiveUIText, actionPrompt: string,
options: SummaryActionOption[],
): Promise<PostSummaryAction | null> { ): Promise<PostSummaryAction | null> {
blankLine(); blankLine();
info(proposedLabel); info(proposedLabel);
console.log(task); console.log(task);
return selectOption<PostSummaryAction>(ui.actionPrompt, [ return selectOption<PostSummaryAction>(actionPrompt, options);
{ label: ui.actions.execute, value: 'execute' }, }
{ label: ui.actions.createIssue, value: 'create_issue' },
{ label: ui.actions.saveTask, value: 'save_task' }, export async function selectPostSummaryAction(
{ label: ui.actions.continue, value: 'continue' }, 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'; export type InteractiveModeAction = 'execute' | 'save_task' | 'create_issue' | 'cancel';

View File

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

View File

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

View File

@ -0,0 +1,81 @@
/**
* Shared post-execution logic: auto-commit, push, and PR creation.
*
* Used by both selectAndExecuteTask (interactive mode) and
* instructBranch (instruct mode from takt list).
*/
import { loadGlobalConfig } from '../../../infra/config/index.js';
import { confirm } from '../../../shared/prompt/index.js';
import { autoCommitAndPush } from '../../../infra/task/index.js';
import { info, error, success } from '../../../shared/ui/index.js';
import { createLogger } from '../../../shared/utils/index.js';
import { createPullRequest, buildPrBody, pushBranch } from '../../../infra/github/index.js';
import type { GitHubIssue } from '../../../infra/github/index.js';
const log = createLogger('postExecution');
/**
* Resolve auto-PR setting with priority: CLI option > config > prompt.
*/
export async function resolveAutoPr(optionAutoPr: boolean | undefined): Promise<boolean> {
if (typeof optionAutoPr === 'boolean') {
return optionAutoPr;
}
const globalConfig = loadGlobalConfig();
if (typeof globalConfig.autoPr === 'boolean') {
return globalConfig.autoPr;
}
return confirm('Create pull request?', true);
}
export interface PostExecutionOptions {
execCwd: string;
projectCwd: string;
task: string;
branch?: string;
baseBranch?: string;
shouldCreatePr: boolean;
pieceIdentifier?: string;
issues?: GitHubIssue[];
repo?: string;
}
/**
* Auto-commit, push, and optionally create a PR after successful task execution.
*/
export async function postExecutionFlow(options: PostExecutionOptions): Promise<void> {
const { execCwd, projectCwd, task, branch, baseBranch, shouldCreatePr, pieceIdentifier, issues, repo } = options;
const commitResult = autoCommitAndPush(execCwd, task, projectCwd);
if (commitResult.success && commitResult.commitHash) {
success(`Auto-committed & pushed: ${commitResult.commitHash}`);
} else if (!commitResult.success) {
error(`Auto-commit failed: ${commitResult.message}`);
}
if (commitResult.success && commitResult.commitHash && branch && shouldCreatePr) {
info('Creating pull request...');
try {
pushBranch(projectCwd, branch);
} catch (pushError) {
log.info('Branch push from project cwd failed (may already exist)', { error: pushError });
}
const report = pieceIdentifier ? `Piece \`${pieceIdentifier}\` completed successfully.` : 'Task completed successfully.';
const prBody = buildPrBody(issues, report);
const prResult = createPullRequest(projectCwd, {
branch,
title: task.length > 100 ? `${task.slice(0, 97)}...` : task,
body: prBody,
base: baseBranch,
repo,
});
if (prResult.success) {
success(`PR created: ${prResult.url}`);
} else {
error(`PR creation failed: ${prResult.error}`);
}
}
}

View File

@ -16,10 +16,11 @@ export interface ResolvedTaskExecution {
taskPrompt?: string; taskPrompt?: string;
reportDirName?: string; reportDirName?: string;
branch?: string; branch?: string;
worktreePath?: string;
baseBranch?: string; baseBranch?: string;
startMovement?: string; startMovement?: string;
retryNote?: string; retryNote?: string;
autoPr?: boolean; autoPr: boolean;
issueNumber?: number; issueNumber?: number;
} }
@ -74,7 +75,7 @@ export async function resolveTaskExecution(
const data = task.data; const data = task.data;
if (!data) { if (!data) {
return { execCwd: defaultCwd, execPiece: defaultPiece, isWorktree: false }; return { execCwd: defaultCwd, execPiece: defaultPiece, isWorktree: false, autoPr: false };
} }
let execCwd = defaultCwd; let execCwd = defaultCwd;
@ -82,6 +83,7 @@ export async function resolveTaskExecution(
let reportDirName: string | undefined; let reportDirName: string | undefined;
let taskPrompt: string | undefined; let taskPrompt: string | undefined;
let branch: string | undefined; let branch: string | undefined;
let worktreePath: string | undefined;
let baseBranch: string | undefined; let baseBranch: string | undefined;
if (task.taskDir) { if (task.taskDir) {
const taskSlug = getTaskSlugFromTaskDir(task.taskDir); const taskSlug = getTaskSlugFromTaskDir(task.taskDir);
@ -114,8 +116,8 @@ export async function resolveTaskExecution(
throwIfAborted(abortSignal); throwIfAborted(abortSignal);
execCwd = result.path; execCwd = result.path;
branch = result.branch; branch = result.branch;
worktreePath = result.path;
isWorktree = true; isWorktree = true;
} }
if (task.taskDir && reportDirName) { if (task.taskDir && reportDirName) {
@ -126,25 +128,26 @@ export async function resolveTaskExecution(
const startMovement = data.start_movement; const startMovement = data.start_movement;
const retryNote = data.retry_note; const retryNote = data.retry_note;
let autoPr: boolean | undefined; let autoPr: boolean;
if (data.auto_pr !== undefined) { if (data.auto_pr !== undefined) {
autoPr = data.auto_pr; autoPr = data.auto_pr;
} else { } else {
const globalConfig = loadGlobalConfig(); const globalConfig = loadGlobalConfig();
autoPr = globalConfig.autoPr; autoPr = globalConfig.autoPr ?? false;
} }
return { return {
execCwd, execCwd,
execPiece, execPiece,
isWorktree, isWorktree,
autoPr,
...(taskPrompt ? { taskPrompt } : {}), ...(taskPrompt ? { taskPrompt } : {}),
...(reportDirName ? { reportDirName } : {}), ...(reportDirName ? { reportDirName } : {}),
...(branch ? { branch } : {}), ...(branch ? { branch } : {}),
...(worktreePath ? { worktreePath } : {}),
...(baseBranch ? { baseBranch } : {}), ...(baseBranch ? { baseBranch } : {}),
...(startMovement ? { startMovement } : {}), ...(startMovement ? { startMovement } : {}),
...(retryNote ? { retryNote } : {}), ...(retryNote ? { retryNote } : {}),
...(autoPr !== undefined ? { autoPr } : {}),
...(data.issue !== undefined ? { issueNumber: data.issue } : {}), ...(data.issue !== undefined ? { issueNumber: data.issue } : {}),
}; };
} }

View File

@ -7,80 +7,24 @@
*/ */
import { import {
getCurrentPiece,
listPieces, listPieces,
listPieceEntries,
isPiecePath, isPiecePath,
loadAllPiecesWithSources,
getPieceCategories,
buildCategorizedPieces,
loadGlobalConfig,
} from '../../../infra/config/index.js'; } from '../../../infra/config/index.js';
import { confirm } from '../../../shared/prompt/index.js'; import { confirm } from '../../../shared/prompt/index.js';
import { createSharedClone, autoCommitAndPush, summarizeTaskName, getCurrentBranch } from '../../../infra/task/index.js'; import { createSharedClone, summarizeTaskName, getCurrentBranch, TaskRunner } from '../../../infra/task/index.js';
import { DEFAULT_PIECE_NAME } from '../../../shared/constants.js'; import { DEFAULT_PIECE_NAME } from '../../../shared/constants.js';
import { info, error, success, withProgress } from '../../../shared/ui/index.js'; import { info, error, withProgress } from '../../../shared/ui/index.js';
import { createLogger } from '../../../shared/utils/index.js'; import { createLogger } from '../../../shared/utils/index.js';
import { createPullRequest, buildPrBody, pushBranch } from '../../../infra/github/index.js';
import { executeTask } from './taskExecution.js'; import { executeTask } from './taskExecution.js';
import { resolveAutoPr, postExecutionFlow } from './postExecution.js';
import type { TaskExecutionOptions, WorktreeConfirmationResult, SelectAndExecuteOptions } from './types.js'; import type { TaskExecutionOptions, WorktreeConfirmationResult, SelectAndExecuteOptions } from './types.js';
import { import { selectPiece } from '../../pieceSelection/index.js';
warnMissingPieces, import { buildBooleanTaskResult, persistTaskError, persistTaskResult } from './taskResultHandler.js';
selectPieceFromCategorizedPieces,
selectPieceFromEntries,
} from '../../pieceSelection/index.js';
export type { WorktreeConfirmationResult, SelectAndExecuteOptions }; export type { WorktreeConfirmationResult, SelectAndExecuteOptions };
const log = createLogger('selectAndExecute'); 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> { export async function determinePiece(cwd: string, override?: string): Promise<string | null> {
if (override) { if (override) {
if (isPiecePath(override)) { if (isPiecePath(override)) {
@ -131,26 +75,6 @@ export async function confirmAndCreateWorktree(
return { execCwd: result.path, isWorktree: true, branch: result.branch, baseBranch }; return { execCwd: result.path, isWorktree: true, branch: result.branch, baseBranch };
} }
/**
* Resolve auto-PR setting with priority: CLI option > config > prompt.
* Only applicable when worktree is enabled.
*/
async function resolveAutoPr(optionAutoPr: boolean | undefined): Promise<boolean> {
// CLI option takes precedence
if (typeof optionAutoPr === 'boolean') {
return optionAutoPr;
}
// Check global config
const globalConfig = loadGlobalConfig();
if (typeof globalConfig.autoPr === 'boolean') {
return globalConfig.autoPr;
}
// Fall back to interactive prompt
return confirm('Create pull request?', true);
}
/** /**
* Execute a task with piece selection, optional worktree, and auto-commit. * Execute a task with piece selection, optional worktree, and auto-commit.
* Shared by direct task execution and interactive mode. * Shared by direct task execution and interactive mode.
@ -181,47 +105,61 @@ export async function selectAndExecuteTask(
} }
log.info('Starting task execution', { piece: pieceIdentifier, worktree: isWorktree, autoPr: shouldCreatePr }); log.info('Starting task execution', { piece: pieceIdentifier, worktree: isWorktree, autoPr: shouldCreatePr });
const taskSuccess = await executeTask({ const taskRunner = new TaskRunner(cwd);
task, const taskRecord = taskRunner.addTask(task, {
cwd: execCwd, piece: pieceIdentifier,
pieceIdentifier, ...(isWorktree ? { worktree: true } : {}),
projectCwd: cwd, ...(branch ? { branch } : {}),
agentOverrides, ...(isWorktree ? { worktree_path: execCwd } : {}),
interactiveUserInput: options?.interactiveUserInput === true, auto_pr: shouldCreatePr,
interactiveMetadata: options?.interactiveMetadata,
}); });
const startedAt = new Date().toISOString();
let taskSuccess: boolean;
try {
taskSuccess = await executeTask({
task,
cwd: execCwd,
pieceIdentifier,
projectCwd: cwd,
agentOverrides,
interactiveUserInput: options?.interactiveUserInput === true,
interactiveMetadata: options?.interactiveMetadata,
});
} catch (err) {
const completedAt = new Date().toISOString();
persistTaskError(taskRunner, taskRecord, startedAt, completedAt, err, {
responsePrefix: 'Task failed: ',
});
throw err;
}
const completedAt = new Date().toISOString();
const taskResult = buildBooleanTaskResult({
task: taskRecord,
taskSuccess,
successResponse: 'Task completed successfully',
failureResponse: 'Task failed',
startedAt,
completedAt,
branch,
...(isWorktree ? { worktreePath: execCwd } : {}),
});
persistTaskResult(taskRunner, taskResult);
if (taskSuccess && isWorktree) { if (taskSuccess && isWorktree) {
const commitResult = autoCommitAndPush(execCwd, task, cwd); await postExecutionFlow({
if (commitResult.success && commitResult.commitHash) { execCwd,
success(`Auto-committed & pushed: ${commitResult.commitHash}`); projectCwd: cwd,
} else if (!commitResult.success) { task,
error(`Auto-commit failed: ${commitResult.message}`); branch,
} baseBranch,
shouldCreatePr,
if (commitResult.success && commitResult.commitHash && branch && shouldCreatePr) { pieceIdentifier,
info('Creating pull request...'); issues: options?.issues,
// Push branch from project cwd to origin (clone's origin is removed after shared clone) repo: options?.repo,
try { });
pushBranch(cwd, branch);
} catch (pushError) {
// Branch may already be pushed by autoCommitAndPush, continue to PR creation
log.info('Branch push from project cwd failed (may already exist)', { error: pushError });
}
const prBody = buildPrBody(options?.issues, `Piece \`${pieceIdentifier}\` completed successfully.`);
const prResult = createPullRequest(cwd, {
branch,
title: task.length > 100 ? `${task.slice(0, 97)}...` : task,
body: prBody,
base: baseBranch,
repo: options?.repo,
});
if (prResult.success) {
success(`PR created: ${prResult.url}`);
} else {
error(`PR creation failed: ${prResult.error}`);
}
}
} }
if (!taskSuccess) { if (!taskSuccess) {

View File

@ -3,12 +3,11 @@
*/ */
import { loadPieceByIdentifier, isPiecePath, loadGlobalConfig } from '../../../infra/config/index.js'; import { loadPieceByIdentifier, isPiecePath, loadGlobalConfig } from '../../../infra/config/index.js';
import { TaskRunner, type TaskInfo, autoCommitAndPush } from '../../../infra/task/index.js'; import { TaskRunner, type TaskInfo } from '../../../infra/task/index.js';
import { import {
header, header,
info, info,
error, error,
success,
status, status,
blankLine, blankLine,
} from '../../../shared/ui/index.js'; } from '../../../shared/ui/index.js';
@ -17,9 +16,11 @@ import { getLabel } from '../../../shared/i18n/index.js';
import { executePiece } from './pieceExecution.js'; import { executePiece } from './pieceExecution.js';
import { DEFAULT_PIECE_NAME } from '../../../shared/constants.js'; import { DEFAULT_PIECE_NAME } from '../../../shared/constants.js';
import type { TaskExecutionOptions, ExecuteTaskOptions, PieceExecutionResult } from './types.js'; import type { TaskExecutionOptions, ExecuteTaskOptions, PieceExecutionResult } from './types.js';
import { createPullRequest, buildPrBody, pushBranch, fetchIssue, checkGhCli } from '../../../infra/github/index.js'; import { fetchIssue, checkGhCli } from '../../../infra/github/index.js';
import { runWithWorkerPool } from './parallelExecution.js'; import { runWithWorkerPool } from './parallelExecution.js';
import { resolveTaskExecution } from './resolveTask.js'; import { resolveTaskExecution } from './resolveTask.js';
import { postExecutionFlow } from './postExecution.js';
import { buildTaskResult, persistTaskError, persistTaskResult } from './taskResultHandler.js';
export type { TaskExecutionOptions, ExecuteTaskOptions }; export type { TaskExecutionOptions, ExecuteTaskOptions };
@ -137,6 +138,7 @@ export async function executeAndCompleteTask(
taskPrompt, taskPrompt,
reportDirName, reportDirName,
branch, branch,
worktreePath,
baseBranch, baseBranch,
startMovement, startMovement,
retryNote, retryNote,
@ -159,80 +161,37 @@ export async function executeAndCompleteTask(
taskColorIndex: parallelOptions?.taskColorIndex, taskColorIndex: parallelOptions?.taskColorIndex,
}); });
if (!taskRunResult.success && !taskRunResult.reason) {
throw new Error('Task failed without reason');
}
const taskSuccess = taskRunResult.success; const taskSuccess = taskRunResult.success;
const completedAt = new Date().toISOString(); const completedAt = new Date().toISOString();
if (taskSuccess && isWorktree) { if (taskSuccess && isWorktree) {
const commitResult = autoCommitAndPush(execCwd, task.name, cwd); const issues = resolveTaskIssue(issueNumber);
if (commitResult.success && commitResult.commitHash) { await postExecutionFlow({
info(`Auto-committed & pushed: ${commitResult.commitHash}`); execCwd,
} else if (!commitResult.success) { projectCwd: cwd,
error(`Auto-commit failed: ${commitResult.message}`); task: task.name,
} branch,
baseBranch,
// Create PR if autoPr is enabled and commit succeeded shouldCreatePr: autoPr,
if (commitResult.success && commitResult.commitHash && branch && autoPr) { pieceIdentifier: execPiece,
info('Creating pull request...'); issues,
// Push branch from project cwd to origin });
try {
pushBranch(cwd, branch);
} catch (pushError) {
// Branch may already be pushed, continue to PR creation
log.info('Branch push from project cwd failed (may already exist)', { error: pushError });
}
const issues = resolveTaskIssue(issueNumber);
const prBody = buildPrBody(issues, `Piece \`${execPiece}\` completed successfully.`);
const prResult = createPullRequest(cwd, {
branch,
title: task.name.length > 100 ? `${task.name.slice(0, 97)}...` : task.name,
body: prBody,
base: baseBranch,
});
if (prResult.success) {
success(`PR created: ${prResult.url}`);
} else {
error(`PR creation failed: ${prResult.error}`);
}
}
} }
const taskResult = { const taskResult = buildTaskResult({
task, task,
success: taskSuccess, runResult: taskRunResult,
response: taskSuccess ? 'Task completed successfully' : taskRunResult.reason!,
executionLog: taskRunResult.lastMessage ? [taskRunResult.lastMessage] : [],
failureMovement: taskRunResult.lastMovement,
failureLastMessage: taskRunResult.lastMessage,
startedAt, startedAt,
completedAt, completedAt,
}; branch,
worktreePath,
if (taskSuccess) { });
taskRunner.completeTask(taskResult); persistTaskResult(taskRunner, taskResult);
success(`Task "${task.name}" completed`);
} else {
taskRunner.failTask(taskResult);
error(`Task "${task.name}" failed`);
}
return taskSuccess; return taskSuccess;
} catch (err) { } catch (err) {
const completedAt = new Date().toISOString(); const completedAt = new Date().toISOString();
persistTaskError(taskRunner, task, startedAt, completedAt, err);
taskRunner.failTask({
task,
success: false,
response: getErrorMessage(err),
executionLog: [],
startedAt,
completedAt,
});
error(`Task "${task.name}" error: ${getErrorMessage(err)}`);
return false; return false;
} finally { } finally {
if (externalAbortSignal) { if (externalAbortSignal) {

View File

@ -0,0 +1,125 @@
import { type TaskInfo, type TaskResult, TaskRunner } from '../../../infra/task/index.js';
import { error, success } from '../../../shared/ui/index.js';
import { getErrorMessage } from '../../../shared/utils/index.js';
import type { PieceExecutionResult } from './types.js';
interface BuildTaskResultParams {
task: TaskInfo;
runResult: PieceExecutionResult;
startedAt: string;
completedAt: string;
branch?: string;
worktreePath?: string;
}
interface BuildBooleanTaskResultParams {
task: TaskInfo;
taskSuccess: boolean;
startedAt: string;
completedAt: string;
successResponse: string;
failureResponse: string;
branch?: string;
worktreePath?: string;
}
interface PersistTaskResultOptions {
emitStatusLog?: boolean;
}
interface PersistTaskErrorOptions {
emitStatusLog?: boolean;
responsePrefix?: string;
}
export function buildTaskResult(params: BuildTaskResultParams): TaskResult {
const { task, runResult, startedAt, completedAt, branch, worktreePath } = params;
const taskSuccess = runResult.success;
if (!taskSuccess && !runResult.reason) {
throw new Error('Task failed without reason');
}
return {
task,
success: taskSuccess,
response: taskSuccess ? 'Task completed successfully' : runResult.reason!,
executionLog: runResult.lastMessage ? [runResult.lastMessage] : [],
failureMovement: runResult.lastMovement,
failureLastMessage: runResult.lastMessage,
startedAt,
completedAt,
...(branch ? { branch } : {}),
...(worktreePath ? { worktreePath } : {}),
};
}
export function buildBooleanTaskResult(params: BuildBooleanTaskResultParams): TaskResult {
const {
task,
taskSuccess,
startedAt,
completedAt,
successResponse,
failureResponse,
branch,
worktreePath,
} = params;
return {
task,
success: taskSuccess,
response: taskSuccess ? successResponse : failureResponse,
executionLog: [],
startedAt,
completedAt,
...(branch ? { branch } : {}),
...(worktreePath ? { worktreePath } : {}),
};
}
export function persistTaskResult(
taskRunner: TaskRunner,
taskResult: TaskResult,
options?: PersistTaskResultOptions,
): void {
const emitStatusLog = options?.emitStatusLog !== false;
if (taskResult.success) {
taskRunner.completeTask(taskResult);
if (emitStatusLog) {
success(`Task "${taskResult.task.name}" completed`);
}
return;
}
taskRunner.failTask(taskResult);
if (emitStatusLog) {
error(`Task "${taskResult.task.name}" failed`);
}
}
export function persistTaskError(
taskRunner: TaskRunner,
task: TaskInfo,
startedAt: string,
completedAt: string,
err: unknown,
options?: PersistTaskErrorOptions,
): void {
const emitStatusLog = options?.emitStatusLog !== false;
const responsePrefix = options?.responsePrefix ?? '';
taskRunner.failTask({
task,
success: false,
response: `${responsePrefix}${getErrorMessage(err)}`,
executionLog: [],
startedAt,
completedAt,
...(task.data?.branch ? { branch: task.data.branch } : {}),
...(task.worktreePath ? { worktreePath: task.worktreePath } : {}),
});
if (emitStatusLog) {
error(`Task "${task.name}" error: ${getErrorMessage(err)}`);
}
}

View File

@ -14,6 +14,7 @@ export {
type SelectAndExecuteOptions, type SelectAndExecuteOptions,
type WorktreeConfirmationResult, type WorktreeConfirmationResult,
} from './execute/selectAndExecute.js'; } from './execute/selectAndExecute.js';
export { resolveAutoPr, postExecutionFlow, type PostExecutionOptions } from './execute/postExecution.js';
export { addTask, saveTaskFile, saveTaskFromInteractive, createIssueFromTask, createIssueAndSaveTask } from './add/index.js'; export { addTask, saveTaskFile, saveTaskFromInteractive, createIssueFromTask, createIssueAndSaveTask } from './add/index.js';
export { watchTasks } from './watch/index.js'; export { watchTasks } from './watch/index.js';
export { export {

View File

@ -9,25 +9,21 @@
*/ */
import { import {
listTaktBranches,
buildListItems,
detectDefaultBranch,
TaskRunner, TaskRunner,
} from '../../../infra/task/index.js'; } from '../../../infra/task/index.js';
import type { TaskListItem } from '../../../infra/task/index.js'; import type { TaskListItem } from '../../../infra/task/index.js';
import { selectOption, confirm } from '../../../shared/prompt/index.js'; import { selectOption } from '../../../shared/prompt/index.js';
import { info, header, blankLine } from '../../../shared/ui/index.js'; import { info, header, blankLine } from '../../../shared/ui/index.js';
import type { TaskExecutionOptions } from '../execute/types.js'; import type { TaskExecutionOptions } from '../execute/types.js';
import { import {
type ListAction, type ListAction,
showFullDiff, showFullDiff,
showDiffAndPromptAction, showDiffAndPromptActionForTask,
tryMergeBranch, tryMergeBranch,
mergeBranch, mergeBranch,
deleteBranch,
instructBranch, instructBranch,
} from './taskActions.js'; } from './taskActions.js';
import { deletePendingTask, deleteFailedTask } from './taskDeleteActions.js'; import { deletePendingTask, deleteFailedTask, deleteCompletedTask } from './taskDeleteActions.js';
import { retryFailedTask } from './taskRetryActions.js'; import { retryFailedTask } from './taskRetryActions.js';
import { listTasksNonInteractive, type ListNonInteractiveOptions } from './listNonInteractive.js'; import { listTasksNonInteractive, type ListNonInteractiveOptions } from './listNonInteractive.js';
import { formatTaskStatusLabel } from './taskStatusLabel.js'; import { formatTaskStatusLabel } from './taskStatusLabel.js';
@ -44,11 +40,18 @@ export {
instructBranch, instructBranch,
} from './taskActions.js'; } from './taskActions.js';
export {
type InstructModeAction,
type InstructModeResult,
runInstructMode,
} from './instructMode.js';
/** Task action type for pending task action selection menu */ /** Task action type for pending task action selection menu */
type PendingTaskAction = 'delete'; type PendingTaskAction = 'delete';
/** Task action type for failed task action selection menu */ /** Task action type for failed task action selection menu */
type FailedTaskAction = 'retry' | 'delete'; type FailedTaskAction = 'retry' | 'delete';
type CompletedTaskAction = ListAction;
/** /**
* Show pending task details and prompt for an action. * Show pending task details and prompt for an action.
@ -74,7 +77,7 @@ async function showPendingTaskAndPromptAction(task: TaskListItem): Promise<Pendi
*/ */
async function showFailedTaskAndPromptAction(task: TaskListItem): Promise<FailedTaskAction | null> { async function showFailedTaskAndPromptAction(task: TaskListItem): Promise<FailedTaskAction | null> {
header(formatTaskStatusLabel(task)); header(formatTaskStatusLabel(task));
info(` Failed at: ${task.createdAt}`); info(` Created: ${task.createdAt}`);
if (task.content) { if (task.content) {
info(` ${task.content}`); info(` ${task.content}`);
} }
@ -89,6 +92,17 @@ async function showFailedTaskAndPromptAction(task: TaskListItem): Promise<Failed
); );
} }
async function showCompletedTaskAndPromptAction(cwd: string, task: TaskListItem): Promise<CompletedTaskAction | null> {
header(formatTaskStatusLabel(task));
info(` Created: ${task.createdAt}`);
if (task.content) {
info(` ${task.content}`);
}
blankLine();
return await showDiffAndPromptActionForTask(cwd, task);
}
/** /**
* Main entry point: list branch-based tasks interactively. * Main entry point: list branch-based tasks interactively.
*/ */
@ -102,44 +116,22 @@ export async function listTasks(
return; return;
} }
const defaultBranch = detectDefaultBranch(cwd);
const runner = new TaskRunner(cwd); const runner = new TaskRunner(cwd);
// Interactive loop // Interactive loop
while (true) { while (true) {
const branches = listTaktBranches(cwd); const tasks = runner.listAllTaskItems();
const items = buildListItems(cwd, branches, defaultBranch);
const pendingTasks = runner.listPendingTaskItems();
const failedTasks = runner.listFailedTasks();
if (items.length === 0 && pendingTasks.length === 0 && failedTasks.length === 0) { if (tasks.length === 0) {
info('No tasks to list.'); info('No tasks to list.');
return; return;
} }
const menuOptions = [ const menuOptions = tasks.map((task, idx) => ({
...items.map((item, idx) => { label: formatTaskStatusLabel(task),
const filesSummary = `${item.filesChanged} file${item.filesChanged !== 1 ? 's' : ''} changed`; value: `${task.kind}:${idx}`,
const description = item.originalInstruction description: `${task.content} | ${task.createdAt}`,
? `${filesSummary} | ${item.originalInstruction}` }));
: filesSummary;
return {
label: item.info.branch,
value: `branch:${idx}`,
description,
};
}),
...pendingTasks.map((task, idx) => ({
label: formatTaskStatusLabel(task),
value: `pending:${idx}`,
description: task.content,
})),
...failedTasks.map((task, idx) => ({
label: formatTaskStatusLabel(task),
value: `failed:${idx}`,
description: task.content,
})),
];
const selected = await selectOption<string>( const selected = await selectOption<string>(
'List Tasks', 'List Tasks',
@ -156,52 +148,55 @@ export async function listTasks(
const idx = parseInt(selected.slice(colonIdx + 1), 10); const idx = parseInt(selected.slice(colonIdx + 1), 10);
if (Number.isNaN(idx)) continue; if (Number.isNaN(idx)) continue;
if (type === 'branch') { if (type === 'pending') {
const item = items[idx]; const task = tasks[idx];
if (!item) continue;
// Action loop: re-show menu after viewing diff
let action: ListAction | null;
do {
action = await showDiffAndPromptAction(cwd, defaultBranch, item);
if (action === 'diff') {
showFullDiff(cwd, defaultBranch, item.info.branch);
}
} while (action === 'diff');
if (action === null) continue;
switch (action) {
case 'instruct':
await instructBranch(cwd, item, options);
break;
case 'try':
tryMergeBranch(cwd, item);
break;
case 'merge':
mergeBranch(cwd, item);
break;
case 'delete': {
const confirmed = await confirm(
`Delete ${item.info.branch}? This will discard all changes.`,
false,
);
if (confirmed) {
deleteBranch(cwd, item);
}
break;
}
}
} else if (type === 'pending') {
const task = pendingTasks[idx];
if (!task) continue; if (!task) continue;
const taskAction = await showPendingTaskAndPromptAction(task); const taskAction = await showPendingTaskAndPromptAction(task);
if (taskAction === 'delete') { if (taskAction === 'delete') {
await deletePendingTask(task); await deletePendingTask(task);
} }
} else if (type === 'running') {
const task = tasks[idx];
if (!task) continue;
header(formatTaskStatusLabel(task));
info(` Created: ${task.createdAt}`);
if (task.content) {
info(` ${task.content}`);
}
blankLine();
info('Running task is read-only.');
blankLine();
} else if (type === 'completed') {
const task = tasks[idx];
if (!task) continue;
if (!task.branch) {
info(`Branch is missing for completed task: ${task.name}`);
continue;
}
const taskAction = await showCompletedTaskAndPromptAction(cwd, task);
if (taskAction === null) continue;
switch (taskAction) {
case 'diff':
showFullDiff(cwd, task.branch);
break;
case 'instruct':
await instructBranch(cwd, task, options);
break;
case 'try':
tryMergeBranch(cwd, task);
break;
case 'merge':
if (mergeBranch(cwd, task)) {
runner.deleteCompletedTask(task.name);
}
break;
case 'delete':
await deleteCompletedTask(task);
break;
}
} else if (type === 'failed') { } else if (type === 'failed') {
const task = failedTasks[idx]; const task = tasks[idx];
if (!task) continue; if (!task) continue;
const taskAction = await showFailedTaskAndPromptAction(task); const taskAction = await showFailedTaskAndPromptAction(task);
if (taskAction === 'retry') { if (taskAction === 'retry') {

View File

@ -0,0 +1,125 @@
/**
* 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,
type PieceContext,
} 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,
pieceContext?: PieceContext,
): 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, pieceContext, undefined);
if (result.action === 'cancel') {
return { action: 'cancel', task: '' };
}
return { action: result.action as InstructModeAction, task: result.task };
}

View File

@ -6,11 +6,9 @@
*/ */
import { execFileSync } from 'node:child_process'; import { execFileSync } from 'node:child_process';
import type { TaskListItem, BranchListItem } from '../../../infra/task/index.js'; import type { TaskListItem } from '../../../infra/task/index.js';
import { import {
detectDefaultBranch, detectDefaultBranch,
listTaktBranches,
buildListItems,
TaskRunner, TaskRunner,
} from '../../../infra/task/index.js'; } from '../../../infra/task/index.js';
import { info } from '../../../shared/ui/index.js'; import { info } from '../../../shared/ui/index.js';
@ -34,34 +32,18 @@ function isValidAction(action: string): action is ListAction {
return action === 'diff' || action === 'try' || action === 'merge' || action === 'delete'; return action === 'diff' || action === 'try' || action === 'merge' || action === 'delete';
} }
function printNonInteractiveList( function printNonInteractiveList(tasks: TaskListItem[], format?: string): void {
items: BranchListItem[],
pendingTasks: TaskListItem[],
failedTasks: TaskListItem[],
format?: string,
): void {
const outputFormat = format ?? 'text'; const outputFormat = format ?? 'text';
if (outputFormat === 'json') { if (outputFormat === 'json') {
// stdout に直接出力JSON パース用途のため UI ヘルパーを経由しない) // stdout に直接出力JSON パース用途のため UI ヘルパーを経由しない)
console.log(JSON.stringify({ console.log(JSON.stringify({
branches: items, tasks,
pendingTasks,
failedTasks,
}, null, 2)); }, null, 2));
return; return;
} }
for (const item of items) { for (const task of tasks) {
const instruction = item.originalInstruction ? ` - ${item.originalInstruction}` : ''; info(`${formatTaskStatusLabel(task)} - ${task.content} (${task.createdAt})`);
info(`${item.info.branch} (${item.filesChanged} files)${instruction}`);
}
for (const task of pendingTasks) {
info(`${formatTaskStatusLabel(task)} - ${task.content}`);
}
for (const task of failedTasks) {
info(`${formatTaskStatusLabel(task)} - ${task.content}`);
} }
} }
@ -85,24 +67,20 @@ export async function listTasksNonInteractive(
nonInteractive: ListNonInteractiveOptions, nonInteractive: ListNonInteractiveOptions,
): Promise<void> { ): Promise<void> {
const defaultBranch = detectDefaultBranch(cwd); const defaultBranch = detectDefaultBranch(cwd);
const branches = listTaktBranches(cwd);
const runner = new TaskRunner(cwd); const runner = new TaskRunner(cwd);
const pendingTasks = runner.listPendingTaskItems(); const tasks = runner.listAllTaskItems();
const failedTasks = runner.listFailedTasks();
const items = buildListItems(cwd, branches, defaultBranch); if (tasks.length === 0) {
if (items.length === 0 && pendingTasks.length === 0 && failedTasks.length === 0) {
info('No tasks to list.'); info('No tasks to list.');
return; return;
} }
if (!nonInteractive.action) { if (!nonInteractive.action) {
printNonInteractiveList(items, pendingTasks, failedTasks, nonInteractive.format); printNonInteractiveList(tasks, nonInteractive.format);
return; return;
} }
// Branch-targeted action (--branch) // Completed-task branch-targeted action (--branch)
if (!nonInteractive.branch) { if (!nonInteractive.branch) {
info('Missing --branch for non-interactive action.'); info('Missing --branch for non-interactive action.');
process.exit(1); process.exit(1);
@ -113,28 +91,32 @@ export async function listTasksNonInteractive(
process.exit(1); process.exit(1);
} }
const item = items.find((entry) => entry.info.branch === nonInteractive.branch); const task = tasks.find((entry) => entry.kind === 'completed' && entry.branch === nonInteractive.branch);
if (!item) { if (!task) {
info(`Branch not found: ${nonInteractive.branch}`); info(`Branch not found: ${nonInteractive.branch}`);
process.exit(1); process.exit(1);
} }
switch (nonInteractive.action) { switch (nonInteractive.action) {
case 'diff': case 'diff':
showDiffStat(cwd, defaultBranch, item.info.branch); showDiffStat(cwd, defaultBranch, nonInteractive.branch);
return; return;
case 'try': case 'try':
tryMergeBranch(cwd, item); tryMergeBranch(cwd, task);
return; return;
case 'merge': case 'merge':
mergeBranch(cwd, item); if (mergeBranch(cwd, task)) {
runner.deleteCompletedTask(task.name);
}
return; return;
case 'delete': case 'delete':
if (!nonInteractive.yes) { if (!nonInteractive.yes) {
info('Delete requires --yes in non-interactive mode.'); info('Delete requires --yes in non-interactive mode.');
process.exit(1); process.exit(1);
} }
deleteBranch(cwd, item); if (deleteBranch(cwd, task)) {
runner.deleteCompletedTask(task.name);
}
return; return;
} }
} }

View File

@ -0,0 +1,29 @@
import type { BranchListItem, TaskListItem } from '../../../infra/task/index.js';
export type ListAction = 'diff' | 'instruct' | 'try' | 'merge' | 'delete';
export type BranchActionTarget = TaskListItem | Pick<BranchListItem, 'info' | 'originalInstruction'>;
export function resolveTargetBranch(target: BranchActionTarget): string {
if ('kind' in target) {
if (!target.branch) {
throw new Error(`Branch is required for task action: ${target.name}`);
}
return target.branch;
}
return target.info.branch;
}
export function resolveTargetWorktreePath(target: BranchActionTarget): string | undefined {
if ('kind' in target) {
return target.worktreePath;
}
return target.info.worktreePath;
}
export function resolveTargetInstruction(target: BranchActionTarget): string {
if ('kind' in target) {
return target.content;
}
return target.originalInstruction;
}

View File

@ -1,390 +1,19 @@
/** /**
* Individual actions for branch-based tasks. * Individual actions for task-centric list items.
*
* Provides merge, delete, try-merge, instruct, and diff operations
* for branches listed by the listTasks command.
*/ */
import { execFileSync, spawnSync } from 'node:child_process'; export type { ListAction } from './taskActionTarget.js';
import { rmSync, existsSync, unlinkSync } from 'node:fs';
import { join } from 'node:path';
import chalk from 'chalk'; export {
import { showFullDiff,
createTempCloneForBranch, showDiffAndPromptActionForTask,
removeClone, } from './taskDiffActions.js';
removeCloneMeta,
cleanupOrphanedClone,
detectDefaultBranch,
autoCommitAndPush,
type BranchListItem,
} from '../../../infra/task/index.js';
import { selectOption, promptInput } 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';
const log = createLogger('list-tasks'); export {
isBranchMerged,
tryMergeBranch,
mergeBranch,
deleteBranch,
} from './taskBranchLifecycleActions.js';
/** Actions available for a listed branch */ export { instructBranch } from './taskInstructionActions.js';
export type ListAction = 'diff' | 'instruct' | 'try' | 'merge' | 'delete';
/**
* Check if a branch has already been merged into HEAD.
*/
export function isBranchMerged(projectDir: string, branch: string): boolean {
const result = spawnSync('git', ['merge-base', '--is-ancestor', branch, 'HEAD'], {
cwd: projectDir,
encoding: 'utf-8',
stdio: 'pipe',
});
if (result.error) {
log.error('Failed to check if branch is merged', {
branch,
error: getErrorMessage(result.error),
});
return false;
}
return result.status === 0;
}
/**
* Show full diff in an interactive pager (less).
* Falls back to direct output if pager is unavailable.
*/
export function showFullDiff(
cwd: string,
defaultBranch: string,
branch: string,
): void {
try {
const result = spawnSync(
'git', ['diff', '--color=always', `${defaultBranch}...${branch}`],
{
cwd,
stdio: 'inherit',
env: { ...process.env, GIT_PAGER: 'less -R' },
},
);
if (result.status !== 0) {
warn('Could not display diff');
}
} catch (err) {
warn('Could not display diff');
log.error('Failed to display full diff', {
branch,
defaultBranch,
error: getErrorMessage(err),
});
}
}
/**
* Show diff stat for a branch and prompt for an action.
*/
export async function showDiffAndPromptAction(
cwd: string,
defaultBranch: string,
item: BranchListItem,
): Promise<ListAction | null> {
header(item.info.branch);
if (item.originalInstruction) {
info(chalk.dim(` ${item.originalInstruction}`));
}
blankLine();
try {
const stat = execFileSync(
'git', ['diff', '--stat', `${defaultBranch}...${item.info.branch}`],
{ cwd, encoding: 'utf-8', stdio: 'pipe' },
);
info(stat);
} catch (err) {
warn('Could not generate diff stat');
log.error('Failed to generate diff stat', {
branch: item.info.branch,
defaultBranch,
error: getErrorMessage(err),
});
}
const action = await selectOption<ListAction>(
`Action for ${item.info.branch}:`,
[
{ label: 'View diff', value: 'diff', description: 'Show full diff in pager' },
{ label: 'Instruct', value: 'instruct', description: 'Give additional instructions via temp clone' },
{ label: 'Try merge', value: 'try', description: 'Squash merge (stage changes without commit)' },
{ label: 'Merge & cleanup', value: 'merge', description: 'Merge and delete branch' },
{ label: 'Delete', value: 'delete', description: 'Discard changes, delete branch' },
],
);
return action;
}
/**
* Try-merge (squash): stage changes from branch without committing.
*/
export function tryMergeBranch(projectDir: string, item: BranchListItem): boolean {
const { branch } = item.info;
try {
execFileSync('git', ['merge', '--squash', branch], {
cwd: projectDir,
encoding: 'utf-8',
stdio: 'pipe',
});
success(`Squash-merged ${branch} (changes staged, not committed)`);
info('Run `git status` to see staged changes, `git commit` to finalize, or `git reset` to undo.');
log.info('Try-merge (squash) completed', { branch });
return true;
} catch (err) {
const msg = getErrorMessage(err);
logError(`Squash merge failed: ${msg}`);
logError('You may need to resolve conflicts manually.');
log.error('Try-merge (squash) failed', { branch, error: msg });
return false;
}
}
/**
* Merge & cleanup: if already merged, skip merge and just delete the branch.
*/
export function mergeBranch(projectDir: string, item: BranchListItem): boolean {
const { branch } = item.info;
const alreadyMerged = isBranchMerged(projectDir, branch);
try {
if (alreadyMerged) {
info(`${branch} is already merged, skipping merge.`);
log.info('Branch already merged, cleanup only', { branch });
} else {
execFileSync('git', ['merge', '--no-edit', branch], {
cwd: projectDir,
encoding: 'utf-8',
stdio: 'pipe',
env: {
...process.env,
GIT_MERGE_AUTOEDIT: 'no',
},
});
}
try {
execFileSync('git', ['branch', '-d', branch], {
cwd: projectDir,
encoding: 'utf-8',
stdio: 'pipe',
});
} catch (err) {
warn(`Could not delete branch ${branch}. You may delete it manually.`);
log.error('Failed to delete merged branch', {
branch,
error: getErrorMessage(err),
});
}
cleanupOrphanedClone(projectDir, branch);
success(`Merged & cleaned up ${branch}`);
log.info('Branch merged & cleaned up', { branch, alreadyMerged });
return true;
} catch (err) {
const msg = getErrorMessage(err);
logError(`Merge failed: ${msg}`);
logError('You may need to resolve conflicts manually.');
log.error('Merge & cleanup failed', { branch, error: msg });
return false;
}
}
/**
* Delete a branch (discard changes).
* For worktree branches, removes the worktree directory and session file.
*/
export function deleteBranch(projectDir: string, item: BranchListItem): boolean {
const { branch, worktreePath } = item.info;
try {
// If this is a worktree branch, remove the worktree directory and session file
if (worktreePath) {
// Remove worktree directory if it exists
if (existsSync(worktreePath)) {
rmSync(worktreePath, { recursive: true, force: true });
log.info('Removed worktree directory', { worktreePath });
}
// Remove worktree-session file
const encodedPath = encodeWorktreePath(worktreePath);
const sessionFile = join(projectDir, '.takt', 'worktree-sessions', `${encodedPath}.json`);
if (existsSync(sessionFile)) {
unlinkSync(sessionFile);
log.info('Removed worktree-session file', { sessionFile });
}
success(`Deleted worktree ${branch}`);
log.info('Worktree branch deleted', { branch, worktreePath });
return true;
}
// For regular branches, use git branch -D
execFileSync('git', ['branch', '-D', branch], {
cwd: projectDir,
encoding: 'utf-8',
stdio: 'pipe',
});
cleanupOrphanedClone(projectDir, branch);
success(`Deleted ${branch}`);
log.info('Branch deleted', { branch });
return true;
} catch (err) {
const msg = getErrorMessage(err);
logError(`Delete failed: ${msg}`);
log.error('Delete failed', { branch, error: msg });
return false;
}
}
/**
* 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.
*/
function getBranchContext(projectDir: string, branch: string): string {
const defaultBranch = detectDefaultBranch(projectDir);
const lines: string[] = [];
try {
const diffStat = execFileSync(
'git', ['diff', '--stat', `${defaultBranch}...${branch}`],
{ cwd: projectDir, encoding: 'utf-8', stdio: 'pipe' },
).trim();
if (diffStat) {
lines.push('## 現在の変更内容mainからの差分');
lines.push('```');
lines.push(diffStat);
lines.push('```');
}
} catch (err) {
log.debug('Failed to collect branch diff stat for instruction context', {
branch,
defaultBranch,
error: getErrorMessage(err),
});
}
try {
const commitLog = execFileSync(
'git', ['log', '--oneline', `${defaultBranch}..${branch}`],
{ cwd: projectDir, encoding: 'utf-8', stdio: 'pipe' },
).trim();
if (commitLog) {
lines.push('');
lines.push('## コミット履歴');
lines.push('```');
lines.push(commitLog);
lines.push('```');
}
} catch (err) {
log.debug('Failed to collect branch commit log for instruction context', {
branch,
defaultBranch,
error: getErrorMessage(err),
});
}
return lines.length > 0 ? lines.join('\n') + '\n\n' : '';
}
/**
* Instruct branch: create a temp clone, give additional instructions,
* auto-commit+push, then remove clone.
*/
export async function instructBranch(
projectDir: string,
item: BranchListItem,
options?: TaskExecutionOptions,
): Promise<boolean> {
const { branch } = item.info;
const instruction = await promptInput('Enter instruction');
if (!instruction) {
info('Cancelled');
return false;
}
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 });
}
return taskSuccess;
} finally {
removeClone(clone.path);
removeCloneMeta(projectDir, branch);
}
}

View File

@ -0,0 +1,141 @@
import { execFileSync, spawnSync } from 'node:child_process';
import { rmSync, existsSync, unlinkSync } from 'node:fs';
import { join } from 'node:path';
import { cleanupOrphanedClone } from '../../../infra/task/index.js';
import { encodeWorktreePath } from '../../../infra/config/project/sessionStore.js';
import { info, success, error as logError, warn } from '../../../shared/ui/index.js';
import { createLogger, getErrorMessage } from '../../../shared/utils/index.js';
import { type BranchActionTarget, resolveTargetBranch, resolveTargetWorktreePath } from './taskActionTarget.js';
const log = createLogger('list-tasks');
export function isBranchMerged(projectDir: string, branch: string): boolean {
const result = spawnSync('git', ['merge-base', '--is-ancestor', branch, 'HEAD'], {
cwd: projectDir,
encoding: 'utf-8',
stdio: 'pipe',
});
if (result.error) {
log.error('Failed to check if branch is merged', {
branch,
error: getErrorMessage(result.error),
});
return false;
}
return result.status === 0;
}
export function tryMergeBranch(projectDir: string, target: BranchActionTarget): boolean {
const branch = resolveTargetBranch(target);
try {
execFileSync('git', ['merge', '--squash', branch], {
cwd: projectDir,
encoding: 'utf-8',
stdio: 'pipe',
});
success(`Squash-merged ${branch} (changes staged, not committed)`);
info('Run `git status` to see staged changes, `git commit` to finalize, or `git reset` to undo.');
log.info('Try-merge (squash) completed', { branch });
return true;
} catch (err) {
const msg = getErrorMessage(err);
logError(`Squash merge failed: ${msg}`);
logError('You may need to resolve conflicts manually.');
log.error('Try-merge (squash) failed', { branch, error: msg });
return false;
}
}
export function mergeBranch(projectDir: string, target: BranchActionTarget): boolean {
const branch = resolveTargetBranch(target);
const alreadyMerged = isBranchMerged(projectDir, branch);
try {
if (alreadyMerged) {
info(`${branch} is already merged, skipping merge.`);
log.info('Branch already merged, cleanup only', { branch });
} else {
execFileSync('git', ['merge', '--no-edit', branch], {
cwd: projectDir,
encoding: 'utf-8',
stdio: 'pipe',
env: {
...process.env,
GIT_MERGE_AUTOEDIT: 'no',
},
});
}
try {
execFileSync('git', ['branch', '-d', branch], {
cwd: projectDir,
encoding: 'utf-8',
stdio: 'pipe',
});
} catch (err) {
warn(`Could not delete branch ${branch}. You may delete it manually.`);
log.error('Failed to delete merged branch', {
branch,
error: getErrorMessage(err),
});
}
cleanupOrphanedClone(projectDir, branch);
success(`Merged & cleaned up ${branch}`);
log.info('Branch merged & cleaned up', { branch, alreadyMerged });
return true;
} catch (err) {
const msg = getErrorMessage(err);
logError(`Merge failed: ${msg}`);
logError('You may need to resolve conflicts manually.');
log.error('Merge & cleanup failed', { branch, error: msg });
return false;
}
}
export function deleteBranch(projectDir: string, target: BranchActionTarget): boolean {
const branch = resolveTargetBranch(target);
const worktreePath = resolveTargetWorktreePath(target);
try {
if (worktreePath) {
if (existsSync(worktreePath)) {
rmSync(worktreePath, { recursive: true, force: true });
log.info('Removed worktree directory', { worktreePath });
}
const encodedPath = encodeWorktreePath(worktreePath);
const sessionFile = join(projectDir, '.takt', 'worktree-sessions', `${encodedPath}.json`);
if (existsSync(sessionFile)) {
unlinkSync(sessionFile);
log.info('Removed worktree-session file', { sessionFile });
}
success(`Deleted worktree ${branch}`);
log.info('Worktree branch deleted', { branch, worktreePath });
return true;
}
execFileSync('git', ['branch', '-D', branch], {
cwd: projectDir,
encoding: 'utf-8',
stdio: 'pipe',
});
cleanupOrphanedClone(projectDir, branch);
success(`Deleted ${branch}`);
log.info('Branch deleted', { branch });
return true;
} catch (err) {
const msg = getErrorMessage(err);
logError(`Delete failed: ${msg}`);
log.error('Delete failed', { branch, error: msg });
return false;
}
}

View File

@ -11,6 +11,7 @@ import { TaskRunner } from '../../../infra/task/index.js';
import { confirm } from '../../../shared/prompt/index.js'; import { confirm } from '../../../shared/prompt/index.js';
import { success, error as logError } from '../../../shared/ui/index.js'; import { success, error as logError } from '../../../shared/ui/index.js';
import { createLogger, getErrorMessage } from '../../../shared/utils/index.js'; import { createLogger, getErrorMessage } from '../../../shared/utils/index.js';
import { deleteBranch } from './taskActions.js';
const log = createLogger('list-tasks'); const log = createLogger('list-tasks');
@ -18,6 +19,14 @@ function getProjectDir(task: TaskListItem): string {
return dirname(dirname(task.filePath)); return dirname(dirname(task.filePath));
} }
function cleanupBranchIfPresent(task: TaskListItem, projectDir: string): boolean {
if (!task.branch) {
return true;
}
return deleteBranch(projectDir, task);
}
/** /**
* Delete a pending task file. * Delete a pending task file.
* Prompts user for confirmation first. * Prompts user for confirmation first.
@ -46,8 +55,13 @@ export async function deletePendingTask(task: TaskListItem): Promise<boolean> {
export async function deleteFailedTask(task: TaskListItem): Promise<boolean> { export async function deleteFailedTask(task: TaskListItem): Promise<boolean> {
const confirmed = await confirm(`Delete failed task "${task.name}"?`, false); const confirmed = await confirm(`Delete failed task "${task.name}"?`, false);
if (!confirmed) return false; if (!confirmed) return false;
const projectDir = getProjectDir(task);
try { try {
const runner = new TaskRunner(getProjectDir(task)); if (!cleanupBranchIfPresent(task, projectDir)) {
return false;
}
const runner = new TaskRunner(projectDir);
runner.deleteFailedTask(task.name); runner.deleteFailedTask(task.name);
} catch (err) { } catch (err) {
const msg = getErrorMessage(err); const msg = getErrorMessage(err);
@ -59,3 +73,27 @@ export async function deleteFailedTask(task: TaskListItem): Promise<boolean> {
log.info('Deleted failed task', { name: task.name, filePath: task.filePath }); log.info('Deleted failed task', { name: task.name, filePath: task.filePath });
return true; return true;
} }
export async function deleteCompletedTask(task: TaskListItem): Promise<boolean> {
const confirmed = await confirm(`Delete completed task "${task.name}"?`, false);
if (!confirmed) return false;
const projectDir = getProjectDir(task);
try {
if (!cleanupBranchIfPresent(task, projectDir)) {
return false;
}
const runner = new TaskRunner(projectDir);
runner.deleteCompletedTask(task.name);
} catch (err) {
const msg = getErrorMessage(err);
logError(`Failed to delete completed task "${task.name}": ${msg}`);
log.error('Failed to delete completed task', { name: task.name, filePath: task.filePath, error: msg });
return false;
}
success(`Deleted completed task: ${task.name}`);
log.info('Deleted completed task', { name: task.name, filePath: task.filePath });
return true;
}

View File

@ -0,0 +1,74 @@
import { execFileSync, spawnSync } from 'node:child_process';
import chalk from 'chalk';
import { detectDefaultBranch } from '../../../infra/task/index.js';
import { selectOption } from '../../../shared/prompt/index.js';
import { info, warn, header, blankLine } from '../../../shared/ui/index.js';
import { createLogger, getErrorMessage } from '../../../shared/utils/index.js';
import { type BranchActionTarget, type ListAction, resolveTargetBranch, resolveTargetInstruction } from './taskActionTarget.js';
const log = createLogger('list-tasks');
export function showFullDiff(cwd: string, branch: string): void {
const defaultBranch = detectDefaultBranch(cwd);
try {
const result = spawnSync(
'git', ['diff', '--color=always', `${defaultBranch}...${branch}`],
{
cwd,
stdio: 'inherit',
env: { ...process.env, GIT_PAGER: 'less -R' },
},
);
if (result.status !== 0) {
warn('Could not display diff');
}
} catch (err) {
warn('Could not display diff');
log.error('Failed to display full diff', {
branch,
defaultBranch,
error: getErrorMessage(err),
});
}
}
export async function showDiffAndPromptActionForTask(
cwd: string,
target: BranchActionTarget,
): Promise<ListAction | null> {
const branch = resolveTargetBranch(target);
const instruction = resolveTargetInstruction(target);
const defaultBranch = detectDefaultBranch(cwd);
header(branch);
if (instruction) {
info(chalk.dim(` ${instruction}`));
}
blankLine();
try {
const stat = execFileSync(
'git', ['diff', '--stat', `${defaultBranch}...${branch}`],
{ cwd, encoding: 'utf-8', stdio: 'pipe' },
);
info(stat);
} catch (err) {
warn('Could not generate diff stat');
log.error('Failed to generate diff stat', {
branch,
defaultBranch,
error: getErrorMessage(err),
});
}
return await selectOption<ListAction>(
`Action for ${branch}:`,
[
{ label: 'View diff', value: 'diff', description: 'Show full diff in pager' },
{ label: 'Instruct', value: 'instruct', description: 'Give additional instructions via temp clone' },
{ label: 'Try merge', value: 'try', description: 'Squash merge (stage changes without commit)' },
{ label: 'Merge & cleanup', value: 'merge', description: 'Merge and delete branch' },
{ label: 'Delete', value: 'delete', description: 'Discard changes, delete branch' },
],
);
}

View File

@ -0,0 +1,185 @@
import { execFileSync } from 'node:child_process';
import {
createTempCloneForBranch,
removeClone,
removeCloneMeta,
TaskRunner,
} from '../../../infra/task/index.js';
import { loadGlobalConfig, getPieceDescription } from '../../../infra/config/index.js';
import { info, success, error as logError } 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 { buildBooleanTaskResult, persistTaskError, persistTaskResult } from '../execute/taskResultHandler.js';
import { runInstructMode } from './instructMode.js';
import { saveTaskFile } from '../add/index.js';
import { selectPiece } from '../../pieceSelection/index.js';
import { dispatchConversationAction } from '../../interactive/actionDispatcher.js';
import type { PieceContext } from '../../interactive/interactive.js';
import { type BranchActionTarget, resolveTargetBranch, resolveTargetWorktreePath } from './taskActionTarget.js';
import { detectDefaultBranch, autoCommitAndPush } from '../../../infra/task/index.js';
const log = createLogger('list-tasks');
function getBranchContext(projectDir: string, branch: string): string {
const defaultBranch = detectDefaultBranch(projectDir);
const lines: string[] = [];
try {
const diffStat = execFileSync(
'git', ['diff', '--stat', `${defaultBranch}...${branch}`],
{ cwd: projectDir, encoding: 'utf-8', stdio: 'pipe' },
).trim();
if (diffStat) {
lines.push('## 現在の変更内容mainからの差分');
lines.push('```');
lines.push(diffStat);
lines.push('```');
}
} catch (err) {
log.debug('Failed to collect branch diff stat for instruction context', {
branch,
defaultBranch,
error: getErrorMessage(err),
});
}
try {
const commitLog = execFileSync(
'git', ['log', '--oneline', `${defaultBranch}..${branch}`],
{ cwd: projectDir, encoding: 'utf-8', stdio: 'pipe' },
).trim();
if (commitLog) {
lines.push('');
lines.push('## コミット履歴');
lines.push('```');
lines.push(commitLog);
lines.push('```');
}
} catch (err) {
log.debug('Failed to collect branch commit log for instruction context', {
branch,
defaultBranch,
error: getErrorMessage(err),
});
}
return lines.length > 0 ? `${lines.join('\n')}\n\n` : '';
}
export async function instructBranch(
projectDir: string,
target: BranchActionTarget,
options?: TaskExecutionOptions,
): Promise<boolean> {
const branch = resolveTargetBranch(target);
const worktreePath = resolveTargetWorktreePath(target);
const selectedPiece = await selectPiece(projectDir);
if (!selectedPiece) {
info('Cancelled');
return false;
}
const globalConfig = loadGlobalConfig();
const pieceDesc = getPieceDescription(selectedPiece, projectDir, globalConfig.interactivePreviewMovements);
const pieceContext: PieceContext = {
name: pieceDesc.name,
description: pieceDesc.description,
pieceStructure: pieceDesc.pieceStructure,
movementPreviews: pieceDesc.movementPreviews,
};
const branchContext = getBranchContext(projectDir, branch);
const result = await runInstructMode(projectDir, branchContext, branch, pieceContext);
return dispatchConversationAction(result, {
cancel: () => {
info('Cancelled');
return false;
},
save_task: async ({ task }) => {
const created = await saveTaskFile(projectDir, task, {
piece: selectedPiece,
worktree: true,
branch,
autoPr: false,
});
success(`Task saved: ${created.taskName}`);
info(` Branch: ${branch}`);
log.info('Task saved from instruct mode', { branch, piece: selectedPiece });
return true;
},
execute: async ({ task }) => {
log.info('Instructing branch via temp clone', { branch, piece: selectedPiece });
info(`Running instruction on ${branch}...`);
const clone = createTempCloneForBranch(projectDir, branch);
const fullInstruction = branchContext
? `${branchContext}## 追加指示\n${task}`
: task;
const runner = new TaskRunner(projectDir);
const taskRecord = runner.addTask(fullInstruction, {
piece: selectedPiece,
worktree: true,
branch,
auto_pr: false,
...(worktreePath ? { worktree_path: worktreePath } : {}),
});
const startedAt = new Date().toISOString();
try {
const taskSuccess = await executeTask({
task: fullInstruction,
cwd: clone.path,
pieceIdentifier: selectedPiece,
projectCwd: projectDir,
agentOverrides: options,
});
const completedAt = new Date().toISOString();
const taskResult = buildBooleanTaskResult({
task: taskRecord,
taskSuccess,
successResponse: 'Instruction completed',
failureResponse: 'Instruction failed',
startedAt,
completedAt,
branch,
...(worktreePath ? { worktreePath } : {}),
});
persistTaskResult(runner, taskResult, { emitStatusLog: false });
if (taskSuccess) {
const commitResult = autoCommitAndPush(clone.path, task, projectDir);
if (commitResult.success && commitResult.commitHash) {
success(`Auto-committed & pushed: ${commitResult.commitHash}`);
} else if (!commitResult.success) {
logError(`Auto-commit failed: ${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;
} catch (err) {
const completedAt = new Date().toISOString();
persistTaskError(runner, taskRecord, startedAt, completedAt, err, {
emitStatusLog: false,
responsePrefix: 'Instruction failed: ',
});
logError(`Instruction failed on ${branch}`);
log.error('Instruction crashed', { branch, error: getErrorMessage(err) });
throw err;
} finally {
removeClone(clone.path);
removeCloneMeta(projectDir, branch);
}
},
});
}

View File

@ -1,7 +1,9 @@
import type { TaskListItem } from '../../../infra/task/index.js'; import type { TaskListItem } from '../../../infra/task/index.js';
const TASK_STATUS_BY_KIND: Record<TaskListItem['kind'], string> = { const TASK_STATUS_BY_KIND: Record<TaskListItem['kind'], string> = {
pending: 'running', pending: 'pending',
running: 'running',
completed: 'completed',
failed: 'failed', failed: 'failed',
}; };

View File

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

View File

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

View File

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

View File

@ -9,6 +9,7 @@ import { readFileSync, existsSync, writeFileSync } from 'node:fs';
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'; import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
import { GlobalConfigSchema } from '../../../core/models/index.js'; import { GlobalConfigSchema } from '../../../core/models/index.js';
import type { GlobalConfig, DebugConfig, Language } 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 { getGlobalConfigPath, getProjectConfigPath } from '../paths.js';
import { DEFAULT_LANGUAGE } from '../../../shared/constants.js'; import { DEFAULT_LANGUAGE } from '../../../shared/constants.js';
import { parseProviderModel } from '../../../shared/utils/providerModel.js'; import { parseProviderModel } from '../../../shared/utils/providerModel.js';
@ -124,6 +125,7 @@ export class GlobalConfigManager {
bookmarksFile: parsed.bookmarks_file, bookmarksFile: parsed.bookmarks_file,
pieceCategoriesFile: parsed.piece_categories_file, pieceCategoriesFile: parsed.piece_categories_file,
personaProviders: parsed.persona_providers, personaProviders: parsed.persona_providers,
providerOptions: normalizeProviderOptions(parsed.provider_options),
branchNameStrategy: parsed.branch_name_strategy, branchNameStrategy: parsed.branch_name_strategy,
preventSleep: parsed.prevent_sleep, preventSleep: parsed.prevent_sleep,
notificationSound: parsed.notification_sound, notificationSound: parsed.notification_sound,

View File

@ -24,34 +24,61 @@ import {
type RawStep = z.output<typeof PieceMovementRawSchema>; 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'], raw: RawStep['provider_options'],
): PieceMovement['providerOptions'] { ): MovementProviderOptions | undefined {
if (!raw) return undefined; if (!raw) return undefined;
const codex = raw.codex?.network_access === undefined const result: MovementProviderOptions = {};
? undefined if (raw.codex?.network_access !== undefined) {
: { networkAccess: raw.codex.network_access }; result.codex = { networkAccess: raw.codex.network_access };
const opencode = raw.opencode?.network_access === undefined }
? undefined if (raw.opencode?.network_access !== undefined) {
: { networkAccess: raw.opencode.network_access }; result.opencode = { networkAccess: raw.opencode.network_access };
}
if (!codex && !opencode) return undefined; if (raw.claude?.sandbox) {
return { ...(codex ? { codex } : {}), ...(opencode ? { opencode } : {}) }; 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'], * Deep merge provider options. Later sources override earlier ones.
override: PieceMovement['providerOptions'], * Exported for reuse in runner.ts (4-layer resolution).
): PieceMovement['providerOptions'] { */
const codexNetworkAccess = override?.codex?.networkAccess ?? base?.codex?.networkAccess; export function mergeProviderOptions(
const opencodeNetworkAccess = override?.opencode?.networkAccess ?? base?.opencode?.networkAccess; ...layers: (MovementProviderOptions | undefined)[]
): MovementProviderOptions | undefined {
const result: MovementProviderOptions = {};
const codex = codexNetworkAccess === undefined ? undefined : { networkAccess: codexNetworkAccess }; for (const layer of layers) {
const opencode = opencodeNetworkAccess === undefined ? undefined : { networkAccess: opencodeNetworkAccess }; 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 Object.keys(result).length > 0 ? result : undefined;
return { ...(codex ? { codex } : {}), ...(opencode ? { opencode } : {}) };
} }
/** Check if a raw output contract item is the object form (has 'name' property). */ /** Check if a raw output contract item is the object form (has 'name' property). */

View File

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

View File

@ -11,7 +11,6 @@ import type { AgentResponse } from '../../core/models/index.js';
import { createLogger, getErrorMessage, createStreamDiagnostics, parseStructuredOutput, type StreamDiagnostics } from '../../shared/utils/index.js'; import { createLogger, getErrorMessage, createStreamDiagnostics, parseStructuredOutput, type StreamDiagnostics } from '../../shared/utils/index.js';
import { parseProviderModel } from '../../shared/utils/providerModel.js'; import { parseProviderModel } from '../../shared/utils/providerModel.js';
import { import {
buildOpenCodePermissionConfig,
buildOpenCodePermissionRuleset, buildOpenCodePermissionRuleset,
mapToOpenCodePermissionReply, mapToOpenCodePermissionReply,
mapToOpenCodeTools, mapToOpenCodeTools,
@ -36,6 +35,7 @@ const OPENCODE_STREAM_ABORTED_MESSAGE = 'OpenCode execution aborted';
const OPENCODE_RETRY_MAX_ATTEMPTS = 3; const OPENCODE_RETRY_MAX_ATTEMPTS = 3;
const OPENCODE_RETRY_BASE_DELAY_MS = 250; const OPENCODE_RETRY_BASE_DELAY_MS = 250;
const OPENCODE_INTERACTION_TIMEOUT_MS = 5000; const OPENCODE_INTERACTION_TIMEOUT_MS = 5000;
const OPENCODE_SERVER_START_TIMEOUT_MS = 60000;
const OPENCODE_RETRYABLE_ERROR_PATTERNS = [ const OPENCODE_RETRYABLE_ERROR_PATTERNS = [
'stream disconnected before completion', 'stream disconnected before completion',
'transport error', 'transport error',
@ -46,8 +46,75 @@ const OPENCODE_RETRYABLE_ERROR_PATTERNS = [
'eai_again', 'eai_again',
'fetch failed', 'fetch failed',
'failed to start server on port', 'failed to start server on port',
'timeout waiting for server',
]; ];
type OpencodeClient = Awaited<ReturnType<typeof createOpencode>>['client'];
interface SharedServer {
client: OpencodeClient;
close: () => void;
model: string;
apiKey?: string;
queue: Array<(client: OpencodeClient) => void>;
}
let sharedServer: SharedServer | null = null;
let initPromise: Promise<void> | null = null;
async function acquireClient(model: string, apiKey?: string, signal?: AbortSignal): Promise<{ client: OpencodeClient; release: () => void }> {
if (initPromise) {
await initPromise;
}
if (sharedServer?.model === model && sharedServer.apiKey === apiKey) {
if (sharedServer.queue.length === 0) {
return { client: sharedServer.client, release: () => releaseClient() };
}
return new Promise((resolve) => {
sharedServer!.queue.push((client) => resolve({ client, release: () => releaseClient() }));
});
}
sharedServer?.close();
let resolveInit: () => void;
initPromise = new Promise((resolve) => { resolveInit = resolve; });
try {
const port = await getFreePort();
const { client, server } = await createOpencode({
port,
signal,
config: {
model,
small_model: model,
...(apiKey ? { provider: { opencode: { options: { apiKey } } } } : {}),
},
timeout: OPENCODE_SERVER_START_TIMEOUT_MS,
});
sharedServer = { client, close: server.close, model, apiKey, queue: [] };
log.debug('OpenCode server started', { model, port });
return { client, release: () => releaseClient() };
} finally {
initPromise = null;
resolveInit!();
}
}
function releaseClient(): void {
if (!sharedServer) return;
const next = sharedServer.queue.shift();
next?.(sharedServer.client);
}
export function resetSharedServer(): void {
sharedServer?.close();
sharedServer = null;
}
async function withTimeout<T>( async function withTimeout<T>(
operation: (signal: AbortSignal) => Promise<T>, operation: (signal: AbortSignal) => Promise<T>,
timeoutMs: number, timeoutMs: number,
@ -270,8 +337,10 @@ export class OpenCodeClient {
const timeoutMessage = `OpenCode stream timed out after ${Math.floor(OPENCODE_STREAM_IDLE_TIMEOUT_MS / 60000)} minutes of inactivity`; const timeoutMessage = `OpenCode stream timed out after ${Math.floor(OPENCODE_STREAM_IDLE_TIMEOUT_MS / 60000)} minutes of inactivity`;
let abortCause: 'timeout' | 'external' | undefined; let abortCause: 'timeout' | 'external' | undefined;
let diagRef: StreamDiagnostics | undefined; let diagRef: StreamDiagnostics | undefined;
let serverClose: (() => void) | undefined; let release: (() => void) | undefined;
let opencodeApiClient: Awaited<ReturnType<typeof createOpencode>>['client'] | undefined; let opencodeApiClient: OpencodeClient | undefined;
let sessionId: string | undefined = options.sessionId;
const interactionTimeoutMs = options.interactionTimeoutMs ?? OPENCODE_INTERACTION_TIMEOUT_MS;
const resetIdleTimeout = (): void => { const resetIdleTimeout = (): void => {
if (idleTimeoutId !== undefined) { if (idleTimeoutId !== undefined) {
@ -310,36 +379,24 @@ export class OpenCodeClient {
const parsedModel = parseProviderModel(options.model, 'OpenCode model'); const parsedModel = parseProviderModel(options.model, 'OpenCode model');
const fullModel = `${parsedModel.providerID}/${parsedModel.modelID}`; const fullModel = `${parsedModel.providerID}/${parsedModel.modelID}`;
const port = await getFreePort();
const permission = buildOpenCodePermissionConfig(options.permissionMode, options.networkAccess);
const config = {
model: fullModel,
small_model: fullModel,
permission,
...(options.opencodeApiKey
? { provider: { opencode: { options: { apiKey: options.opencodeApiKey } } } }
: {}),
};
const { client, server } = await createOpencode({
port,
signal: streamAbortController.signal,
config,
});
opencodeApiClient = client;
serverClose = server.close;
const sessionResult = options.sessionId const acquired = await acquireClient(fullModel, options.opencodeApiKey, streamAbortController.signal);
? { data: { id: options.sessionId } } opencodeApiClient = acquired.client;
: await client.session.create({ release = acquired.release;
const sessionResult = sessionId
? { data: { id: sessionId } }
: await opencodeApiClient.session.create({
directory: options.cwd, directory: options.cwd,
permission: buildOpenCodePermissionRuleset(options.permissionMode, options.networkAccess), permission: buildOpenCodePermissionRuleset(options.permissionMode, options.networkAccess),
}); });
const sessionId = sessionResult.data?.id; sessionId = sessionResult.data?.id;
if (!sessionId) { if (!sessionId) {
release();
throw new Error('Failed to create OpenCode session'); throw new Error('Failed to create OpenCode session');
} }
const { stream } = await client.event.subscribe( const { stream } = await opencodeApiClient.event.subscribe(
{ directory: options.cwd }, { directory: options.cwd },
{ signal: streamAbortController.signal }, { signal: streamAbortController.signal },
); );
@ -361,9 +418,8 @@ export class OpenCodeClient {
}; };
} }
// OpenCode SDK types do not yet expose outputFormat even though runtime accepts it. const promptPayloadForSdk = promptPayload as unknown as Parameters<typeof opencodeApiClient.session.promptAsync>[0];
const promptPayloadForSdk = promptPayload as unknown as Parameters<typeof client.session.promptAsync>[0]; await opencodeApiClient.session.promptAsync(promptPayloadForSdk, {
await client.session.promptAsync(promptPayloadForSdk, {
signal: streamAbortController.signal, signal: streamAbortController.signal,
}); });
@ -418,18 +474,24 @@ export class OpenCodeClient {
sessionID: string; sessionID: string;
}; };
if (permProps.sessionID === sessionId) { if (permProps.sessionID === sessionId) {
const reply = options.permissionMode try {
? mapToOpenCodePermissionReply(options.permissionMode) const reply = options.permissionMode
: 'once'; ? mapToOpenCodePermissionReply(options.permissionMode)
await withTimeout( : 'once';
(signal) => client.permission.reply({ await withTimeout(
requestID: permProps.id, (signal) => opencodeApiClient!.permission.reply({
directory: options.cwd, requestID: permProps.id,
reply, directory: options.cwd,
}, { signal }), reply,
OPENCODE_INTERACTION_TIMEOUT_MS, }, { signal }),
'OpenCode permission reply timed out', interactionTimeoutMs,
); 'OpenCode permission reply timed out',
);
} catch (e) {
success = false;
failureMessage = getErrorMessage(e);
break;
}
} }
continue; continue;
} }
@ -438,39 +500,37 @@ export class OpenCodeClient {
const questionProps = sseEvent.properties as OpenCodeQuestionAskedProperties; const questionProps = sseEvent.properties as OpenCodeQuestionAskedProperties;
if (questionProps.sessionID === sessionId) { if (questionProps.sessionID === sessionId) {
if (!options.onAskUserQuestion) { if (!options.onAskUserQuestion) {
await withTimeout( try {
(signal) => client.question.reject({ await withTimeout(
requestID: questionProps.id, (signal) => opencodeApiClient!.question.reject({
directory: options.cwd, requestID: questionProps.id,
}, { signal }), directory: options.cwd,
OPENCODE_INTERACTION_TIMEOUT_MS, }, { signal }),
'OpenCode question reject timed out', interactionTimeoutMs,
); 'OpenCode question reject timed out',
);
} catch (e) {
success = false;
failureMessage = getErrorMessage(e);
break;
}
continue; continue;
} }
try { try {
const answers = await options.onAskUserQuestion(toQuestionInput(questionProps)); const answers = await options.onAskUserQuestion(toQuestionInput(questionProps));
await withTimeout( await withTimeout(
(signal) => client.question.reply({ (signal) => opencodeApiClient!.question.reply({
requestID: questionProps.id, requestID: questionProps.id,
directory: options.cwd, directory: options.cwd,
answers: toQuestionAnswers(questionProps, answers), answers: toQuestionAnswers(questionProps, answers),
}, { signal }), }, { signal }),
OPENCODE_INTERACTION_TIMEOUT_MS, interactionTimeoutMs,
'OpenCode question reply timed out', 'OpenCode question reply timed out',
); );
} catch { } catch (e) {
await withTimeout(
(signal) => client.question.reject({
requestID: questionProps.id,
directory: options.cwd,
}, { signal }),
OPENCODE_INTERACTION_TIMEOUT_MS,
'OpenCode question reject timed out',
);
success = false; success = false;
failureMessage = 'OpenCode question handling failed'; failureMessage = getErrorMessage(e);
break; break;
} }
} }
@ -629,8 +689,8 @@ export class OpenCodeClient {
continue; continue;
} }
if (options.sessionId) { if (sessionId) {
emitResult(options.onStream, false, errorMessage, options.sessionId); emitResult(options.onStream, false, errorMessage, sessionId);
} }
return { return {
@ -638,7 +698,7 @@ export class OpenCodeClient {
status: 'error', status: 'error',
content: errorMessage, content: errorMessage,
timestamp: new Date(), timestamp: new Date(),
sessionId: options.sessionId, sessionId,
}; };
} finally { } finally {
if (idleTimeoutId !== undefined) { if (idleTimeoutId !== undefined) {
@ -663,9 +723,7 @@ export class OpenCodeClient {
clearTimeout(disposeTimeoutId); clearTimeout(disposeTimeoutId);
} }
} }
if (serverClose) { release?.();
serverClose();
}
if (!streamAbortController.signal.aborted) { if (!streamAbortController.signal.aborted) {
streamAbortController.abort(); streamAbortController.abort();
} }

View File

@ -187,15 +187,11 @@ export interface OpenCodeCallOptions {
model: string; model: string;
systemPrompt?: string; systemPrompt?: string;
allowedTools?: string[]; allowedTools?: string[];
/** Permission mode for automatic permission handling */
permissionMode?: PermissionMode; permissionMode?: PermissionMode;
/** Override network access (webfetch/websearch) */
networkAccess?: boolean; networkAccess?: boolean;
/** Enable streaming mode with callback (best-effort) */
onStream?: StreamCallback; onStream?: StreamCallback;
onAskUserQuestion?: AskUserQuestionHandler; onAskUserQuestion?: AskUserQuestionHandler;
/** OpenCode API key */
opencodeApiKey?: string; opencodeApiKey?: string;
/** JSON Schema for structured output */
outputSchema?: Record<string, unknown>; outputSchema?: Record<string, unknown>;
interactionTimeoutMs?: number;
} }

View File

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

View File

@ -3,7 +3,7 @@
*/ */
import type { StreamCallback, PermissionHandler, AskUserQuestionHandler } from '../claude/index.js'; import type { 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 */ /** Agent setup configuration — determines HOW the provider invokes the agent */
export interface AgentSetup { export interface AgentSetup {
@ -24,28 +24,17 @@ export interface ProviderCallOptions {
sessionId?: string; sessionId?: string;
model?: string; model?: string;
allowedTools?: string[]; allowedTools?: string[];
/** MCP servers configuration */
mcpServers?: Record<string, McpServerConfig>; mcpServers?: Record<string, McpServerConfig>;
/** Maximum number of agentic turns */
maxTurns?: number; maxTurns?: number;
/** Permission mode for tool execution (from piece step) */
permissionMode?: PermissionMode; permissionMode?: PermissionMode;
/** Provider-specific movement options */ providerOptions?: MovementProviderOptions;
providerOptions?: {
codex?: { networkAccess?: boolean };
opencode?: { networkAccess?: boolean };
};
onStream?: StreamCallback; onStream?: StreamCallback;
onPermissionRequest?: PermissionHandler; onPermissionRequest?: PermissionHandler;
onAskUserQuestion?: AskUserQuestionHandler; onAskUserQuestion?: AskUserQuestionHandler;
bypassPermissions?: boolean; bypassPermissions?: boolean;
/** Anthropic API key for Claude provider */
anthropicApiKey?: string; anthropicApiKey?: string;
/** OpenAI API key for Codex provider */
openaiApiKey?: string; openaiApiKey?: string;
/** OpenCode API key for OpenCode provider */
opencodeApiKey?: string; opencodeApiKey?: string;
/** JSON Schema for structured output */
outputSchema?: Record<string, unknown>; outputSchema?: Record<string, unknown>;
} }

View File

@ -42,7 +42,7 @@ export class CloneManager {
? globalConfig.worktreeDir ? globalConfig.worktreeDir
: path.resolve(projectDir, globalConfig.worktreeDir); : path.resolve(projectDir, globalConfig.worktreeDir);
} }
return path.join(projectDir, '..'); return path.join(projectDir, '..', 'takt-worktree');
} }
/** Resolve the clone path based on options and global config */ /** Resolve the clone path based on options and global config */

View File

@ -70,6 +70,7 @@ export function toTaskInfo(projectDir: string, tasksFile: string, task: TaskReco
taskDir: task.task_dir, taskDir: task.task_dir,
createdAt: task.created_at, createdAt: task.created_at,
status: task.status, status: task.status,
worktreePath: task.worktree_path,
data: TaskFileSchema.parse({ data: TaskFileSchema.parse({
task: content, task: content,
worktree: task.worktree, worktree: task.worktree,
@ -86,22 +87,53 @@ export function toTaskInfo(projectDir: string, tasksFile: string, task: TaskReco
export function toPendingTaskItem(projectDir: string, tasksFile: string, task: TaskRecord): TaskListItem { export function toPendingTaskItem(projectDir: string, tasksFile: string, task: TaskRecord): TaskListItem {
return { return {
kind: 'pending', kind: 'pending',
name: task.name, ...toBaseTaskListItem(projectDir, tasksFile, task),
createdAt: task.created_at,
filePath: tasksFile,
content: firstLine(resolveTaskContent(projectDir, task)),
data: toTaskData(projectDir, task),
}; };
} }
export function toFailedTaskItem(projectDir: string, tasksFile: string, task: TaskRecord): TaskListItem { export function toFailedTaskItem(projectDir: string, tasksFile: string, task: TaskRecord): TaskListItem {
return { return {
kind: 'failed', kind: 'failed',
name: task.name, ...toBaseTaskListItem(projectDir, tasksFile, task),
createdAt: task.completed_at ?? task.created_at,
filePath: tasksFile,
content: firstLine(resolveTaskContent(projectDir, task)),
data: toTaskData(projectDir, task),
failure: task.failure, failure: task.failure,
}; };
} }
function toRunningTaskItem(projectDir: string, tasksFile: string, task: TaskRecord): TaskListItem {
return {
kind: 'running',
...toBaseTaskListItem(projectDir, tasksFile, task),
};
}
function toCompletedTaskItem(projectDir: string, tasksFile: string, task: TaskRecord): TaskListItem {
return {
kind: 'completed',
...toBaseTaskListItem(projectDir, tasksFile, task),
};
}
function toBaseTaskListItem(projectDir: string, tasksFile: string, task: TaskRecord): Omit<TaskListItem, 'kind' | 'failure'> {
return {
name: task.name,
createdAt: task.created_at,
filePath: tasksFile,
content: firstLine(resolveTaskContent(projectDir, task)),
branch: task.branch,
worktreePath: task.worktree_path,
data: toTaskData(projectDir, task),
};
}
export function toTaskListItem(projectDir: string, tasksFile: string, task: TaskRecord): TaskListItem {
switch (task.status) {
case 'pending':
return toPendingTaskItem(projectDir, tasksFile, task);
case 'running':
return toRunningTaskItem(projectDir, tasksFile, task);
case 'completed':
return toCompletedTaskItem(projectDir, tasksFile, task);
case 'failed':
return toFailedTaskItem(projectDir, tasksFile, task);
}
}

View File

@ -1,24 +1,25 @@
import * as path from 'node:path'; import type { TaskFileData } from './schema.js';
import {
TaskRecordSchema,
type TaskFileData,
type TaskRecord,
type TaskFailure,
} from './schema.js';
import type { TaskInfo, TaskResult, TaskListItem } from './types.js'; import type { TaskInfo, TaskResult, TaskListItem } from './types.js';
import { toFailedTaskItem, toPendingTaskItem, toTaskInfo } from './mapper.js';
import { TaskStore } from './store.js'; import { TaskStore } from './store.js';
import { firstLine, nowIso, sanitizeTaskName } from './naming.js'; import { TaskLifecycleService } from './taskLifecycleService.js';
import { TaskQueryService } from './taskQueryService.js';
import { TaskDeletionService } from './taskDeletionService.js';
export type { TaskInfo, TaskResult, TaskListItem }; export type { TaskInfo, TaskResult, TaskListItem };
export class TaskRunner { export class TaskRunner {
private readonly store: TaskStore; private readonly store: TaskStore;
private readonly tasksFile: string; private readonly tasksFile: string;
private readonly lifecycle: TaskLifecycleService;
private readonly query: TaskQueryService;
private readonly deletion: TaskDeletionService;
constructor(private readonly projectDir: string) { constructor(private readonly projectDir: string) {
this.store = new TaskStore(projectDir); this.store = new TaskStore(projectDir);
this.tasksFile = this.store.getTasksFilePath(); this.tasksFile = this.store.getTasksFilePath();
this.lifecycle = new TaskLifecycleService(projectDir, this.tasksFile, this.store);
this.query = new TaskQueryService(projectDir, this.tasksFile, this.store);
this.deletion = new TaskDeletionService(this.store);
} }
ensureDirs(): void { ensureDirs(): void {
@ -31,247 +32,56 @@ export class TaskRunner {
addTask( addTask(
content: string, content: string,
options?: Omit<TaskFileData, 'task'> & { content_file?: string; task_dir?: string }, options?: Omit<TaskFileData, 'task'> & { content_file?: string; task_dir?: string; worktree_path?: string },
): TaskInfo { ): TaskInfo {
const state = this.store.update((current) => { return this.lifecycle.addTask(content, options);
const name = this.generateTaskName(content, current.tasks.map((task) => task.name));
const contentValue = options?.task_dir ? undefined : content;
const record: TaskRecord = TaskRecordSchema.parse({
name,
status: 'pending',
content: contentValue,
created_at: nowIso(),
started_at: null,
completed_at: null,
owner_pid: null,
...options,
});
return { tasks: [...current.tasks, record] };
});
const created = state.tasks[state.tasks.length - 1];
if (!created) {
throw new Error('Failed to create task.');
}
return toTaskInfo(this.projectDir, this.tasksFile, created);
} }
listTasks(): TaskInfo[] { listTasks(): TaskInfo[] {
const state = this.store.read(); return this.query.listTasks();
return state.tasks
.filter((task) => task.status === 'pending')
.map((task) => toTaskInfo(this.projectDir, this.tasksFile, task));
} }
claimNextTasks(count: number): TaskInfo[] { claimNextTasks(count: number): TaskInfo[] {
if (count <= 0) { return this.lifecycle.claimNextTasks(count);
return [];
}
const claimed: TaskRecord[] = [];
this.store.update((current) => {
let remaining = count;
const tasks = current.tasks.map((task) => {
if (remaining > 0 && task.status === 'pending') {
const next: TaskRecord = {
...task,
status: 'running',
started_at: nowIso(),
owner_pid: process.pid,
};
claimed.push(next);
remaining--;
return next;
}
return task;
});
return { tasks };
});
return claimed.map((task) => toTaskInfo(this.projectDir, this.tasksFile, task));
} }
recoverInterruptedRunningTasks(): number { recoverInterruptedRunningTasks(): number {
let recovered = 0; return this.lifecycle.recoverInterruptedRunningTasks();
this.store.update((current) => {
const tasks = current.tasks.map((task) => {
if (task.status !== 'running' || !this.isRunningTaskStale(task)) {
return task;
}
recovered++;
return {
...task,
status: 'pending',
started_at: null,
owner_pid: null,
} as TaskRecord;
});
return { tasks };
});
return recovered;
} }
completeTask(result: TaskResult): string { completeTask(result: TaskResult): string {
if (!result.success) { return this.lifecycle.completeTask(result);
throw new Error('Cannot complete a failed task. Use failTask() instead.');
}
this.store.update((current) => {
const index = this.findActiveTaskIndex(current.tasks, result.task.name);
if (index === -1) {
throw new Error(`Task not found: ${result.task.name}`);
}
return {
tasks: current.tasks.filter((_, i) => i !== index),
};
});
return this.tasksFile;
} }
failTask(result: TaskResult): string { failTask(result: TaskResult): string {
const failure: TaskFailure = { return this.lifecycle.failTask(result);
movement: result.failureMovement,
error: result.response,
last_message: result.failureLastMessage ?? result.executionLog[result.executionLog.length - 1],
};
this.store.update((current) => {
const index = this.findActiveTaskIndex(current.tasks, result.task.name);
if (index === -1) {
throw new Error(`Task not found: ${result.task.name}`);
}
const target = current.tasks[index]!;
const updated: TaskRecord = {
...target,
status: 'failed',
completed_at: result.completedAt,
owner_pid: null,
failure,
};
const tasks = [...current.tasks];
tasks[index] = updated;
return { tasks };
});
return this.tasksFile;
} }
listPendingTaskItems(): TaskListItem[] { listPendingTaskItems(): TaskListItem[] {
const state = this.store.read(); return this.query.listPendingTaskItems();
return state.tasks }
.filter((task) => task.status === 'pending')
.map((task) => toPendingTaskItem(this.projectDir, this.tasksFile, task)); listAllTaskItems(): TaskListItem[] {
return this.query.listAllTaskItems();
} }
listFailedTasks(): TaskListItem[] { listFailedTasks(): TaskListItem[] {
const state = this.store.read(); return this.query.listFailedTasks();
return state.tasks
.filter((task) => task.status === 'failed')
.map((task) => toFailedTaskItem(this.projectDir, this.tasksFile, task));
} }
requeueFailedTask(taskRef: string, startMovement?: string, retryNote?: string): string { requeueFailedTask(taskRef: string, startMovement?: string, retryNote?: string): string {
const taskName = this.normalizeTaskRef(taskRef); return this.lifecycle.requeueFailedTask(taskRef, startMovement, retryNote);
this.store.update((current) => {
const index = current.tasks.findIndex((task) => task.name === taskName && task.status === 'failed');
if (index === -1) {
throw new Error(`Failed task not found: ${taskRef}`);
}
const target = current.tasks[index]!;
const updated: TaskRecord = {
...target,
status: 'pending',
started_at: null,
completed_at: null,
owner_pid: null,
failure: undefined,
start_movement: startMovement,
retry_note: retryNote,
};
const tasks = [...current.tasks];
tasks[index] = updated;
return { tasks };
});
return this.tasksFile;
} }
deletePendingTask(name: string): void { deletePendingTask(name: string): void {
this.deleteTaskByNameAndStatus(name, 'pending'); this.deletion.deletePendingTask(name);
} }
deleteFailedTask(name: string): void { deleteFailedTask(name: string): void {
this.deleteTaskByNameAndStatus(name, 'failed'); this.deletion.deleteFailedTask(name);
} }
private deleteTaskByNameAndStatus(name: string, status: 'pending' | 'failed'): void { deleteCompletedTask(name: string): void {
this.store.update((current) => { this.deletion.deleteCompletedTask(name);
const exists = current.tasks.some((task) => task.name === name && task.status === status);
if (!exists) {
throw new Error(`Task not found: ${name} (${status})`);
}
return {
tasks: current.tasks.filter((task) => !(task.name === name && task.status === status)),
};
});
} }
private normalizeTaskRef(taskRef: string): string {
if (!taskRef.includes(path.sep)) {
return taskRef;
}
const base = path.basename(taskRef);
if (base.includes('_')) {
return base.slice(base.indexOf('_') + 1);
}
return base;
}
private findActiveTaskIndex(tasks: TaskRecord[], name: string): number {
return tasks.findIndex((task) => task.name === name && (task.status === 'running' || task.status === 'pending'));
}
private isRunningTaskStale(task: TaskRecord): boolean {
if (task.owner_pid == null) {
return true;
}
return !this.isProcessAlive(task.owner_pid);
}
private isProcessAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch (err) {
const nodeErr = err as NodeJS.ErrnoException;
if (nodeErr.code === 'ESRCH') {
return false;
}
if (nodeErr.code === 'EPERM') {
return true;
}
throw err;
}
}
private generateTaskName(content: string, existingNames: string[]): string {
const base = sanitizeTaskName(firstLine(content));
let candidate = base;
let counter = 1;
while (existingNames.includes(candidate)) {
candidate = `${base}-${counter}`;
counter++;
}
return candidate;
}
} }

View File

@ -41,6 +41,7 @@ export type TaskFailure = z.infer<typeof TaskFailureSchema>;
export const TaskRecordSchema = TaskExecutionConfigSchema.extend({ export const TaskRecordSchema = TaskExecutionConfigSchema.extend({
name: z.string().min(1), name: z.string().min(1),
status: TaskStatusSchema, status: TaskStatusSchema,
worktree_path: z.string().optional(),
content: z.string().min(1).optional(), content: z.string().min(1).optional(),
content_file: z.string().min(1).optional(), content_file: z.string().min(1).optional(),
task_dir: z.string().optional(), task_dir: z.string().optional(),

View File

@ -0,0 +1,29 @@
import { TaskStore } from './store.js';
export class TaskDeletionService {
constructor(private readonly store: TaskStore) {}
deletePendingTask(name: string): void {
this.deleteTaskByNameAndStatus(name, 'pending');
}
deleteFailedTask(name: string): void {
this.deleteTaskByNameAndStatus(name, 'failed');
}
deleteCompletedTask(name: string): void {
this.deleteTaskByNameAndStatus(name, 'completed');
}
private deleteTaskByNameAndStatus(name: string, status: 'pending' | 'failed' | 'completed'): void {
this.store.update((current) => {
const exists = current.tasks.some((task) => task.name === name && task.status === status);
if (!exists) {
throw new Error(`Task not found: ${name} (${status})`);
}
return {
tasks: current.tasks.filter((task) => !(task.name === name && task.status === status)),
};
});
}
}

View File

@ -0,0 +1,232 @@
import * as path from 'node:path';
import { TaskRecordSchema, type TaskFileData, type TaskRecord, type TaskFailure } from './schema.js';
import type { TaskInfo, TaskResult } from './types.js';
import { toTaskInfo } from './mapper.js';
import { TaskStore } from './store.js';
import { firstLine, nowIso, sanitizeTaskName } from './naming.js';
export class TaskLifecycleService {
constructor(
private readonly projectDir: string,
private readonly tasksFile: string,
private readonly store: TaskStore,
) {}
addTask(
content: string,
options?: Omit<TaskFileData, 'task'> & { content_file?: string; task_dir?: string; worktree_path?: string },
): TaskInfo {
const state = this.store.update((current) => {
const name = this.generateTaskName(content, current.tasks.map((task) => task.name));
const contentValue = options?.task_dir ? undefined : content;
const record: TaskRecord = TaskRecordSchema.parse({
name,
status: 'pending',
content: contentValue,
created_at: nowIso(),
started_at: null,
completed_at: null,
owner_pid: null,
...options,
});
return { tasks: [...current.tasks, record] };
});
const created = state.tasks[state.tasks.length - 1];
if (!created) {
throw new Error('Failed to create task.');
}
return toTaskInfo(this.projectDir, this.tasksFile, created);
}
claimNextTasks(count: number): TaskInfo[] {
if (count <= 0) {
return [];
}
const claimed: TaskRecord[] = [];
this.store.update((current) => {
let remaining = count;
const tasks = current.tasks.map((task) => {
if (remaining > 0 && task.status === 'pending') {
const next: TaskRecord = {
...task,
status: 'running',
started_at: nowIso(),
owner_pid: process.pid,
};
claimed.push(next);
remaining--;
return next;
}
return task;
});
return { tasks };
});
return claimed.map((task) => toTaskInfo(this.projectDir, this.tasksFile, task));
}
recoverInterruptedRunningTasks(): number {
let recovered = 0;
this.store.update((current) => {
const tasks = current.tasks.map((task) => {
if (task.status !== 'running' || !this.isRunningTaskStale(task)) {
return task;
}
recovered++;
return {
...task,
status: 'pending',
started_at: null,
owner_pid: null,
} as TaskRecord;
});
return { tasks };
});
return recovered;
}
completeTask(result: TaskResult): string {
if (!result.success) {
throw new Error('Cannot complete a failed task. Use failTask() instead.');
}
this.store.update((current) => {
const index = this.findActiveTaskIndex(current.tasks, result.task.name);
if (index === -1) {
throw new Error(`Task not found: ${result.task.name}`);
}
const target = current.tasks[index]!;
const updated: TaskRecord = {
...target,
status: 'completed',
started_at: result.startedAt,
completed_at: result.completedAt,
owner_pid: null,
failure: undefined,
branch: result.branch ?? target.branch,
worktree_path: result.worktreePath ?? target.worktree_path,
};
const tasks = [...current.tasks];
tasks[index] = updated;
return { tasks };
});
return this.tasksFile;
}
failTask(result: TaskResult): string {
const failure: TaskFailure = {
movement: result.failureMovement,
error: result.response,
last_message: result.failureLastMessage ?? result.executionLog[result.executionLog.length - 1],
};
this.store.update((current) => {
const index = this.findActiveTaskIndex(current.tasks, result.task.name);
if (index === -1) {
throw new Error(`Task not found: ${result.task.name}`);
}
const target = current.tasks[index]!;
const updated: TaskRecord = {
...target,
status: 'failed',
started_at: result.startedAt,
completed_at: result.completedAt,
owner_pid: null,
failure,
branch: result.branch ?? target.branch,
worktree_path: result.worktreePath ?? target.worktree_path,
};
const tasks = [...current.tasks];
tasks[index] = updated;
return { tasks };
});
return this.tasksFile;
}
requeueFailedTask(taskRef: string, startMovement?: string, retryNote?: string): string {
const taskName = this.normalizeTaskRef(taskRef);
this.store.update((current) => {
const index = current.tasks.findIndex((task) => task.name === taskName && task.status === 'failed');
if (index === -1) {
throw new Error(`Failed task not found: ${taskRef}`);
}
const target = current.tasks[index]!;
const updated: TaskRecord = {
...target,
status: 'pending',
started_at: null,
completed_at: null,
owner_pid: null,
failure: undefined,
start_movement: startMovement,
retry_note: retryNote,
};
const tasks = [...current.tasks];
tasks[index] = updated;
return { tasks };
});
return this.tasksFile;
}
private normalizeTaskRef(taskRef: string): string {
if (!taskRef.includes(path.sep)) {
return taskRef;
}
const base = path.basename(taskRef);
if (base.includes('_')) {
return base.slice(base.indexOf('_') + 1);
}
return base;
}
private findActiveTaskIndex(tasks: TaskRecord[], name: string): number {
return tasks.findIndex((task) => task.name === name && (task.status === 'running' || task.status === 'pending'));
}
private isRunningTaskStale(task: TaskRecord): boolean {
if (task.owner_pid == null) {
return true;
}
return !this.isProcessAlive(task.owner_pid);
}
private isProcessAlive(pid: number): boolean {
try {
process.kill(pid, 0);
return true;
} catch (err) {
const nodeErr = err as NodeJS.ErrnoException;
if (nodeErr.code === 'ESRCH') {
return false;
}
if (nodeErr.code === 'EPERM') {
return true;
}
throw err;
}
}
private generateTaskName(content: string, existingNames: string[]): string {
const base = sanitizeTaskName(firstLine(content));
let candidate = base;
let counter = 1;
while (existingNames.includes(candidate)) {
candidate = `${base}-${counter}`;
counter++;
}
return candidate;
}
}

View File

@ -0,0 +1,37 @@
import type { TaskInfo, TaskListItem } from './types.js';
import { toFailedTaskItem, toPendingTaskItem, toTaskInfo, toTaskListItem } from './mapper.js';
import { TaskStore } from './store.js';
export class TaskQueryService {
constructor(
private readonly projectDir: string,
private readonly tasksFile: string,
private readonly store: TaskStore,
) {}
listTasks(): TaskInfo[] {
const state = this.store.read();
return state.tasks
.filter((task) => task.status === 'pending')
.map((task) => toTaskInfo(this.projectDir, this.tasksFile, task));
}
listPendingTaskItems(): TaskListItem[] {
const state = this.store.read();
return state.tasks
.filter((task) => task.status === 'pending')
.map((task) => toPendingTaskItem(this.projectDir, this.tasksFile, task));
}
listAllTaskItems(): TaskListItem[] {
const state = this.store.read();
return state.tasks.map((task) => toTaskListItem(this.projectDir, this.tasksFile, task));
}
listFailedTasks(): TaskListItem[] {
const state = this.store.read();
return state.tasks
.filter((task) => task.status === 'failed')
.map((task) => toFailedTaskItem(this.projectDir, this.tasksFile, task));
}
}

View File

@ -13,6 +13,7 @@ export interface TaskInfo {
taskDir?: string; taskDir?: string;
createdAt: string; createdAt: string;
status: TaskStatus; status: TaskStatus;
worktreePath?: string;
data: TaskFileData | null; data: TaskFileData | null;
} }
@ -26,6 +27,8 @@ export interface TaskResult {
failureLastMessage?: string; failureLastMessage?: string;
startedAt: string; startedAt: string;
completedAt: string; completedAt: string;
branch?: string;
worktreePath?: string;
} }
export interface WorktreeOptions { export interface WorktreeOptions {
@ -73,11 +76,13 @@ export interface SummarizeOptions {
/** pending/failedタスクのリストアイテム */ /** pending/failedタスクのリストアイテム */
export interface TaskListItem { export interface TaskListItem {
kind: 'pending' | 'failed'; kind: 'pending' | 'running' | 'completed' | 'failed';
name: string; name: string;
createdAt: string; createdAt: string;
filePath: string; filePath: string;
content: string; content: string;
branch?: string;
worktreePath?: string;
data?: TaskFileData; data?: TaskFileData;
failure?: TaskFailure; failure?: TaskFailure;
} }

View File

@ -68,6 +68,22 @@ piece:
sigintTimeout: "Graceful shutdown timed out after {timeoutMs}ms" sigintTimeout: "Graceful shutdown timed out after {timeoutMs}ms"
sigintForce: "Ctrl+C: Force exit" 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: run:
notifyComplete: "Run complete ({total} tasks)" notifyComplete: "Run complete ({total} tasks)"
notifyAbort: "Run finished with errors ({failed})" notifyAbort: "Run finished with errors ({failed})"

View File

@ -68,6 +68,22 @@ piece:
sigintTimeout: "graceful停止がタイムアウトしました ({timeoutMs}ms)" sigintTimeout: "graceful停止がタイムアウトしました ({timeoutMs}ms)"
sigintForce: "Ctrl+C: 強制終了します" 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: run:
notifyComplete: "run完了 ({total} tasks)" notifyComplete: "run完了 ({total} tasks)"
notifyAbort: "runはエラー終了 ({failed})" notifyAbort: "runはエラー終了 ({failed})"