commit
dc5dda1afb
4
.gitignore
vendored
4
.gitignore
vendored
@ -33,3 +33,7 @@ coverage/
|
|||||||
|
|
||||||
task_planning/
|
task_planning/
|
||||||
|
|
||||||
|
OPENCODE_CONFIG_CONTENT
|
||||||
|
|
||||||
|
# Local editor/agent settings
|
||||||
|
.claude/
|
||||||
|
|||||||
31
CHANGELOG.md
31
CHANGELOG.md
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -8,6 +8,7 @@ AI特有の問題についてコードをレビューしてください:
|
|||||||
- もっともらしいが間違っているパターン
|
- もっともらしいが間違っているパターン
|
||||||
- 既存コードベースとの適合性
|
- 既存コードベースとの適合性
|
||||||
- スコープクリープの検出
|
- スコープクリープの検出
|
||||||
|
- スコープ縮小の検出(タスク要件の取りこぼし)
|
||||||
|
|
||||||
## 判定手順
|
## 判定手順
|
||||||
|
|
||||||
|
|||||||
@ -18,6 +18,7 @@
|
|||||||
- **指示書に明記されていない別ファイルを「参照資料の代わり」として使うことは禁止**
|
- **指示書に明記されていない別ファイルを「参照資料の代わり」として使うことは禁止**
|
||||||
2. タスクの要件を理解する
|
2. タスクの要件を理解する
|
||||||
- 参照資料の内容と現在の実装を突き合わせて差分を特定する
|
- 参照資料の内容と現在の実装を突き合わせて差分を特定する
|
||||||
|
- **要件ごとに「変更要/不要」を判定する。「不要」の場合は現行コードの該当箇所(ファイル:行)を根拠として示すこと。根拠なしの「既に正しい」は禁止**
|
||||||
3. コードを調査して不明点を解決する
|
3. コードを調査して不明点を解決する
|
||||||
4. 影響範囲を特定する
|
4. 影響範囲を特定する
|
||||||
5. ファイル構成・設計パターンを決定する(必要な場合)
|
5. ファイル構成・設計パターンを決定する(必要な場合)
|
||||||
|
|||||||
@ -3,7 +3,8 @@
|
|||||||
**ピース全体の確認:**
|
**ピース全体の確認:**
|
||||||
1. 計画と実装結果が一致しているか
|
1. 計画と実装結果が一致しているか
|
||||||
2. 各レビュームーブメントの指摘が対応されているか
|
2. 各レビュームーブメントの指摘が対応されているか
|
||||||
3. 元のタスク目的が達成されているか
|
3. タスク指示書の各要件が達成されているか
|
||||||
|
- 計画レポートの判断を鵜呑みにせず、要件ごとに実コード(ファイル:行)で独立照合する
|
||||||
|
|
||||||
**レポートの確認:** Report Directory内の全レポートを読み、
|
**レポートの確認:** Report Directory内の全レポートを読み、
|
||||||
未対応の改善提案がないか確認してください。
|
未対応の改善提案がないか確認してください。
|
||||||
|
|||||||
168
docs/provider-sandbox.md
Normal file
168
docs/provider-sandbox.md
Normal file
@ -0,0 +1,168 @@
|
|||||||
|
# Provider Sandbox Configuration
|
||||||
|
|
||||||
|
TAKT supports configuring sandbox settings for AI agent providers. This document covers how sandbox isolation works across providers, how to configure it, and the security trade-offs.
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
| Provider | Sandbox Mechanism | Build Tool Issues | TAKT Configuration |
|
||||||
|
|----------|------------------|-------------------|-------------------|
|
||||||
|
| **Claude Code** | macOS Seatbelt / Linux bubblewrap | Gradle/JVM blocked in `edit` mode | `provider_options.claude.sandbox` |
|
||||||
|
| **Codex CLI** | macOS Seatbelt / Linux Landlock+seccomp | npm/maven/pytest failures (widespread) | `provider_options.codex.network_access` |
|
||||||
|
| **OpenCode CLI** | None (no native sandbox) | No constraints (no security either) | N/A |
|
||||||
|
|
||||||
|
## Claude Code Sandbox
|
||||||
|
|
||||||
|
### The Problem
|
||||||
|
|
||||||
|
When a movement uses `permission_mode: edit` (mapped to Claude SDK's `acceptEdits`), Bash commands run inside a macOS Seatbelt sandbox. This sandbox blocks:
|
||||||
|
|
||||||
|
- Writes outside the working directory (e.g., `~/.gradle`)
|
||||||
|
- Certain system calls required by JVM initialization
|
||||||
|
- Network access (by default)
|
||||||
|
|
||||||
|
As a result, build tools like Gradle, Maven, or any JVM-based tool fail with `Operation not permitted`.
|
||||||
|
|
||||||
|
### Solution: `provider_options.claude.sandbox`
|
||||||
|
|
||||||
|
TAKT exposes Claude SDK's `SandboxSettings` through `provider_options.claude.sandbox` at four configuration levels.
|
||||||
|
|
||||||
|
#### Option A: `allow_unsandboxed_commands` (Recommended)
|
||||||
|
|
||||||
|
Allow all Bash commands to run outside the sandbox while keeping file edit permissions controlled:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
provider_options:
|
||||||
|
claude:
|
||||||
|
sandbox:
|
||||||
|
allow_unsandboxed_commands: true
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Option B: `excluded_commands`
|
||||||
|
|
||||||
|
Exclude only specific commands from the sandbox:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
provider_options:
|
||||||
|
claude:
|
||||||
|
sandbox:
|
||||||
|
excluded_commands:
|
||||||
|
- ./gradlew
|
||||||
|
- npm
|
||||||
|
- npx
|
||||||
|
```
|
||||||
|
|
||||||
|
### Configuration Levels
|
||||||
|
|
||||||
|
Settings are merged with the following priority (highest wins):
|
||||||
|
|
||||||
|
```
|
||||||
|
Movement > Piece > Project Local > Global
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Global (`~/.takt/config.yaml`)
|
||||||
|
|
||||||
|
Applies to all projects and all pieces:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# ~/.takt/config.yaml
|
||||||
|
provider_options:
|
||||||
|
claude:
|
||||||
|
sandbox:
|
||||||
|
allow_unsandboxed_commands: true
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Project Local (`.takt/config.yaml`)
|
||||||
|
|
||||||
|
Applies to this project only:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# .takt/config.yaml
|
||||||
|
provider_options:
|
||||||
|
claude:
|
||||||
|
sandbox:
|
||||||
|
excluded_commands:
|
||||||
|
- ./gradlew
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Piece (`piece_config` section)
|
||||||
|
|
||||||
|
Applies to all movements in this piece:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# pieces/my-piece.yaml
|
||||||
|
piece_config:
|
||||||
|
provider_options:
|
||||||
|
claude:
|
||||||
|
sandbox:
|
||||||
|
allow_unsandboxed_commands: true
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Movement (per step)
|
||||||
|
|
||||||
|
Applies to a specific movement only:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
movements:
|
||||||
|
- name: implement
|
||||||
|
permission_mode: edit
|
||||||
|
provider_options:
|
||||||
|
claude:
|
||||||
|
sandbox:
|
||||||
|
allow_unsandboxed_commands: true
|
||||||
|
- name: review
|
||||||
|
permission_mode: readonly
|
||||||
|
# No sandbox config needed — readonly doesn't sandbox Bash
|
||||||
|
```
|
||||||
|
|
||||||
|
### Security Risk Comparison
|
||||||
|
|
||||||
|
| Configuration | File Edits | Network | Bash Commands | CWD-external Writes | Risk Level |
|
||||||
|
|--------------|-----------|---------|---------------|---------------------|------------|
|
||||||
|
| `permission_mode: edit` (default) | Permitted | Blocked | Sandboxed | Blocked | Low |
|
||||||
|
| `excluded_commands: [./gradlew]` | Permitted | Blocked | Only `./gradlew` unsandboxed | Only via `./gradlew` | Low |
|
||||||
|
| `allow_unsandboxed_commands: true` | Permitted | Allowed | Unsandboxed | Allowed via Bash | **Medium** |
|
||||||
|
| `permission_mode: full` | All permitted | Allowed | Unsandboxed | All permitted | **High** |
|
||||||
|
|
||||||
|
**Key difference between `allow_unsandboxed_commands` and `permission_mode: full`:**
|
||||||
|
- `allow_unsandboxed_commands`: File edits still require Claude Code's permission check (`acceptEdits` mode). Only Bash is unsandboxed.
|
||||||
|
- `permission_mode: full`: All permission checks are bypassed (`bypassPermissions` mode). No guardrails at all.
|
||||||
|
|
||||||
|
### Practical Risk Assessment
|
||||||
|
|
||||||
|
The "Medium" risk of `allow_unsandboxed_commands` is manageable in practice because:
|
||||||
|
|
||||||
|
- TAKT runs locally on the developer's machine (not a public-facing service)
|
||||||
|
- Input comes from task instructions written by the developer
|
||||||
|
- Agent behavior is reviewed by the supervisor movement
|
||||||
|
- File edit operations still go through Claude Code's permission system
|
||||||
|
|
||||||
|
## Codex CLI Sandbox
|
||||||
|
|
||||||
|
Codex CLI uses macOS Seatbelt (same as Claude Code) but has **more severe compatibility issues** with build tools. Community reports show npm, Maven, pytest, and other tools frequently failing with `Operation not permitted` — even when the same commands work in Claude Code.
|
||||||
|
|
||||||
|
Codex sandbox is configured via `~/.codex/config.toml` (not through TAKT):
|
||||||
|
|
||||||
|
```toml
|
||||||
|
# ~/.codex/config.toml
|
||||||
|
sandbox_mode = "workspace-write"
|
||||||
|
|
||||||
|
[sandbox_workspace_write]
|
||||||
|
network_access = true
|
||||||
|
writable_roots = ["/Users/YOU/.gradle"]
|
||||||
|
```
|
||||||
|
|
||||||
|
TAKT provides `provider_options.codex.network_access` to control network access via the Codex SDK:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
provider_options:
|
||||||
|
codex:
|
||||||
|
network_access: true
|
||||||
|
```
|
||||||
|
|
||||||
|
For other sandbox settings (writable_roots, sandbox_mode), configure directly in `~/.codex/config.toml`.
|
||||||
|
|
||||||
|
## OpenCode CLI Sandbox
|
||||||
|
|
||||||
|
OpenCode CLI does not have a native sandbox mechanism. All commands run without filesystem or network restrictions. For isolation, the community recommends Docker containers (e.g., [opencode-sandbox](https://github.com/fabianlema/opencode-sandbox)).
|
||||||
|
|
||||||
|
No TAKT-side sandbox configuration is needed or available for OpenCode.
|
||||||
@ -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
4
package-lock.json
generated
@ -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",
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
39
src/__tests__/actionDispatcher.test.ts
Normal file
39
src/__tests__/actionDispatcher.test.ts
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
import { describe, it, expect, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { dispatchConversationAction } from '../features/interactive/actionDispatcher.js';
|
||||||
|
|
||||||
|
describe('dispatchConversationAction', () => {
|
||||||
|
it('should dispatch to matching handler with full result payload', async () => {
|
||||||
|
const execute = vi.fn().mockResolvedValue('executed');
|
||||||
|
const saveTask = vi.fn().mockResolvedValue('saved');
|
||||||
|
const cancel = vi.fn().mockResolvedValue('cancelled');
|
||||||
|
|
||||||
|
const result = await dispatchConversationAction(
|
||||||
|
{ action: 'save_task', task: 'refine branch docs' },
|
||||||
|
{
|
||||||
|
execute,
|
||||||
|
save_task: saveTask,
|
||||||
|
cancel,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toBe('saved');
|
||||||
|
expect(saveTask).toHaveBeenCalledWith({ action: 'save_task', task: 'refine branch docs' });
|
||||||
|
expect(execute).not.toHaveBeenCalled();
|
||||||
|
expect(cancel).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should support synchronous handlers', async () => {
|
||||||
|
const result = await dispatchConversationAction(
|
||||||
|
{ action: 'cancel', task: '' },
|
||||||
|
{
|
||||||
|
execute: () => true,
|
||||||
|
save_task: () => true,
|
||||||
|
cancel: () => false,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
@ -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');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
282
src/__tests__/instructMode.test.ts
Normal file
282
src/__tests__/instructMode.test.ts
Normal file
@ -0,0 +1,282 @@
|
|||||||
|
/**
|
||||||
|
* Tests for instruct mode
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||||
|
|
||||||
|
vi.mock('../infra/config/global/globalConfig.js', () => ({
|
||||||
|
loadGlobalConfig: vi.fn(() => ({ provider: 'mock', language: 'en' })),
|
||||||
|
getBuiltinPiecesEnabled: vi.fn().mockReturnValue(true),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../infra/providers/index.js', () => ({
|
||||||
|
getProvider: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../shared/utils/index.js', async (importOriginal) => ({
|
||||||
|
...(await importOriginal<Record<string, unknown>>()),
|
||||||
|
createLogger: () => ({
|
||||||
|
info: vi.fn(),
|
||||||
|
debug: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../shared/context.js', () => ({
|
||||||
|
isQuietMode: vi.fn(() => false),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../infra/config/paths.js', async (importOriginal) => ({
|
||||||
|
...(await importOriginal<Record<string, unknown>>()),
|
||||||
|
loadPersonaSessions: vi.fn(() => ({})),
|
||||||
|
updatePersonaSession: vi.fn(),
|
||||||
|
getProjectConfigDir: vi.fn(() => '/tmp'),
|
||||||
|
loadSessionState: vi.fn(() => null),
|
||||||
|
clearSessionState: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../shared/ui/index.js', () => ({
|
||||||
|
info: vi.fn(),
|
||||||
|
error: vi.fn(),
|
||||||
|
blankLine: vi.fn(),
|
||||||
|
StreamDisplay: vi.fn().mockImplementation(() => ({
|
||||||
|
createHandler: vi.fn(() => vi.fn()),
|
||||||
|
flush: vi.fn(),
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../shared/prompt/index.js', () => ({
|
||||||
|
selectOption: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../shared/i18n/index.js', () => ({
|
||||||
|
getLabel: vi.fn((_key: string, _lang: string) => 'Mock label'),
|
||||||
|
getLabelObject: vi.fn(() => ({
|
||||||
|
intro: 'Instruct mode intro',
|
||||||
|
resume: 'Resuming',
|
||||||
|
noConversation: 'No conversation',
|
||||||
|
summarizeFailed: 'Summarize failed',
|
||||||
|
continuePrompt: 'Continue',
|
||||||
|
proposed: 'Proposed task:',
|
||||||
|
actionPrompt: 'What to do?',
|
||||||
|
actions: {
|
||||||
|
execute: 'Execute',
|
||||||
|
saveTask: 'Save task',
|
||||||
|
continue: 'Continue',
|
||||||
|
},
|
||||||
|
cancelled: 'Cancelled',
|
||||||
|
})),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock('../shared/prompts/index.js', () => ({
|
||||||
|
loadTemplate: vi.fn((_name: string, _lang: string) => 'Mock template content'),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import { getProvider } from '../infra/providers/index.js';
|
||||||
|
import { runInstructMode } from '../features/tasks/list/instructMode.js';
|
||||||
|
import { selectOption } from '../shared/prompt/index.js';
|
||||||
|
import { info } from '../shared/ui/index.js';
|
||||||
|
|
||||||
|
const mockGetProvider = vi.mocked(getProvider);
|
||||||
|
const mockSelectOption = vi.mocked(selectOption);
|
||||||
|
const mockInfo = vi.mocked(info);
|
||||||
|
|
||||||
|
let savedIsTTY: boolean | undefined;
|
||||||
|
let savedIsRaw: boolean | undefined;
|
||||||
|
let savedSetRawMode: typeof process.stdin.setRawMode | undefined;
|
||||||
|
let savedStdoutWrite: typeof process.stdout.write;
|
||||||
|
let savedStdinOn: typeof process.stdin.on;
|
||||||
|
let savedStdinRemoveListener: typeof process.stdin.removeListener;
|
||||||
|
let savedStdinResume: typeof process.stdin.resume;
|
||||||
|
let savedStdinPause: typeof process.stdin.pause;
|
||||||
|
|
||||||
|
function setupRawStdin(rawInputs: string[]): void {
|
||||||
|
savedIsTTY = process.stdin.isTTY;
|
||||||
|
savedIsRaw = process.stdin.isRaw;
|
||||||
|
savedSetRawMode = process.stdin.setRawMode;
|
||||||
|
savedStdoutWrite = process.stdout.write;
|
||||||
|
savedStdinOn = process.stdin.on;
|
||||||
|
savedStdinRemoveListener = process.stdin.removeListener;
|
||||||
|
savedStdinResume = process.stdin.resume;
|
||||||
|
savedStdinPause = process.stdin.pause;
|
||||||
|
|
||||||
|
Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true });
|
||||||
|
Object.defineProperty(process.stdin, 'isRaw', { value: false, configurable: true, writable: true });
|
||||||
|
process.stdin.setRawMode = vi.fn((mode: boolean) => {
|
||||||
|
(process.stdin as unknown as { isRaw: boolean }).isRaw = mode;
|
||||||
|
return process.stdin;
|
||||||
|
}) as unknown as typeof process.stdin.setRawMode;
|
||||||
|
process.stdout.write = vi.fn(() => true) as unknown as typeof process.stdout.write;
|
||||||
|
process.stdin.resume = vi.fn(() => process.stdin) as unknown as typeof process.stdin.resume;
|
||||||
|
process.stdin.pause = vi.fn(() => process.stdin) as unknown as typeof process.stdin.pause;
|
||||||
|
|
||||||
|
let currentHandler: ((data: Buffer) => void) | null = null;
|
||||||
|
let inputIndex = 0;
|
||||||
|
|
||||||
|
process.stdin.on = vi.fn(((event: string, handler: (...args: unknown[]) => void) => {
|
||||||
|
if (event === 'data') {
|
||||||
|
currentHandler = handler as (data: Buffer) => void;
|
||||||
|
if (inputIndex < rawInputs.length) {
|
||||||
|
const data = rawInputs[inputIndex]!;
|
||||||
|
inputIndex++;
|
||||||
|
queueMicrotask(() => {
|
||||||
|
if (currentHandler) {
|
||||||
|
currentHandler(Buffer.from(data, 'utf-8'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return process.stdin;
|
||||||
|
}) as typeof process.stdin.on);
|
||||||
|
|
||||||
|
process.stdin.removeListener = vi.fn(((event: string) => {
|
||||||
|
if (event === 'data') {
|
||||||
|
currentHandler = null;
|
||||||
|
}
|
||||||
|
return process.stdin;
|
||||||
|
}) as typeof process.stdin.removeListener);
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreStdin(): void {
|
||||||
|
if (savedIsTTY !== undefined) {
|
||||||
|
Object.defineProperty(process.stdin, 'isTTY', { value: savedIsTTY, configurable: true });
|
||||||
|
}
|
||||||
|
if (savedIsRaw !== undefined) {
|
||||||
|
Object.defineProperty(process.stdin, 'isRaw', { value: savedIsRaw, configurable: true, writable: true });
|
||||||
|
}
|
||||||
|
if (savedSetRawMode) {
|
||||||
|
process.stdin.setRawMode = savedSetRawMode;
|
||||||
|
}
|
||||||
|
if (savedStdoutWrite) {
|
||||||
|
process.stdout.write = savedStdoutWrite;
|
||||||
|
}
|
||||||
|
if (savedStdinOn) {
|
||||||
|
process.stdin.on = savedStdinOn;
|
||||||
|
}
|
||||||
|
if (savedStdinRemoveListener) {
|
||||||
|
process.stdin.removeListener = savedStdinRemoveListener;
|
||||||
|
}
|
||||||
|
if (savedStdinResume) {
|
||||||
|
process.stdin.resume = savedStdinResume;
|
||||||
|
}
|
||||||
|
if (savedStdinPause) {
|
||||||
|
process.stdin.pause = savedStdinPause;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function toRawInputs(inputs: (string | null)[]): string[] {
|
||||||
|
return inputs.map((input) => {
|
||||||
|
if (input === null) return '\x04';
|
||||||
|
return input + '\r';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setupMockProvider(responses: string[]): void {
|
||||||
|
let callIndex = 0;
|
||||||
|
const mockCall = vi.fn(async () => {
|
||||||
|
const content = callIndex < responses.length ? responses[callIndex] : 'AI response';
|
||||||
|
callIndex++;
|
||||||
|
return {
|
||||||
|
persona: 'instruct',
|
||||||
|
status: 'done' as const,
|
||||||
|
content: content!,
|
||||||
|
timestamp: new Date(),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const mockProvider = {
|
||||||
|
setup: () => ({ call: mockCall }),
|
||||||
|
_call: mockCall,
|
||||||
|
};
|
||||||
|
mockGetProvider.mockReturnValue(mockProvider);
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
mockSelectOption.mockResolvedValue('execute');
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
restoreStdin();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('runInstructMode', () => {
|
||||||
|
it('should return action=cancel when user types /cancel', async () => {
|
||||||
|
setupRawStdin(toRawInputs(['/cancel']));
|
||||||
|
setupMockProvider([]);
|
||||||
|
|
||||||
|
const result = await runInstructMode('/project', 'branch context', 'feature-branch');
|
||||||
|
|
||||||
|
expect(result.action).toBe('cancel');
|
||||||
|
expect(result.task).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should include branch name in intro message', async () => {
|
||||||
|
setupRawStdin(toRawInputs(['/cancel']));
|
||||||
|
setupMockProvider([]);
|
||||||
|
|
||||||
|
await runInstructMode('/project', 'diff stats', 'my-feature-branch');
|
||||||
|
|
||||||
|
const introCall = mockInfo.mock.calls.find((call) =>
|
||||||
|
call[0]?.includes('my-feature-branch')
|
||||||
|
);
|
||||||
|
expect(introCall).toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return action=execute with task on /go after conversation', async () => {
|
||||||
|
setupRawStdin(toRawInputs(['add more tests', '/go']));
|
||||||
|
setupMockProvider(['What kind of tests?', 'Add unit tests for the feature.']);
|
||||||
|
|
||||||
|
const result = await runInstructMode('/project', 'branch context', 'feature-branch');
|
||||||
|
|
||||||
|
expect(result.action).toBe('execute');
|
||||||
|
expect(result.task).toBe('Add unit tests for the feature.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should return action=save_task when user selects save task', async () => {
|
||||||
|
setupRawStdin(toRawInputs(['describe task', '/go']));
|
||||||
|
setupMockProvider(['response', 'Summarized task.']);
|
||||||
|
mockSelectOption.mockResolvedValue('save_task');
|
||||||
|
|
||||||
|
const result = await runInstructMode('/project', 'branch context', 'feature-branch');
|
||||||
|
|
||||||
|
expect(result.action).toBe('save_task');
|
||||||
|
expect(result.task).toBe('Summarized task.');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should continue editing when user selects continue', async () => {
|
||||||
|
setupRawStdin(toRawInputs(['describe task', '/go', '/cancel']));
|
||||||
|
setupMockProvider(['response', 'Summarized task.']);
|
||||||
|
mockSelectOption.mockResolvedValueOnce('continue');
|
||||||
|
|
||||||
|
const result = await runInstructMode('/project', 'branch context', 'feature-branch');
|
||||||
|
|
||||||
|
expect(result.action).toBe('cancel');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject /go with no prior conversation', async () => {
|
||||||
|
setupRawStdin(toRawInputs(['/go', '/cancel']));
|
||||||
|
setupMockProvider([]);
|
||||||
|
|
||||||
|
const result = await runInstructMode('/project', 'branch context', 'feature-branch');
|
||||||
|
|
||||||
|
expect(result.action).toBe('cancel');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should use custom action selector without create_issue option', async () => {
|
||||||
|
setupRawStdin(toRawInputs(['task', '/go']));
|
||||||
|
setupMockProvider(['response', 'Task summary.']);
|
||||||
|
|
||||||
|
await runInstructMode('/project', 'branch context', 'feature-branch');
|
||||||
|
|
||||||
|
const selectCall = mockSelectOption.mock.calls.find((call) =>
|
||||||
|
Array.isArray(call[1])
|
||||||
|
);
|
||||||
|
expect(selectCall).toBeDefined();
|
||||||
|
const options = selectCall![1] as Array<{ value: string }>;
|
||||||
|
const values = options.map((o) => o.value);
|
||||||
|
expect(values).toContain('execute');
|
||||||
|
expect(values).toContain('save_task');
|
||||||
|
expect(values).toContain('continue');
|
||||||
|
expect(values).not.toContain('create_issue');
|
||||||
|
});
|
||||||
|
});
|
||||||
83
src/__tests__/listNonInteractive-completedActions.test.ts
Normal file
83
src/__tests__/listNonInteractive-completedActions.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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();
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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();
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
147
src/__tests__/listTasksInteractiveStatusActions.test.ts
Normal file
147
src/__tests__/listTasksInteractiveStatusActions.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
210
src/__tests__/option-resolution-order.test.ts
Normal file
210
src/__tests__/option-resolution-order.test.ts
Normal 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' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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]!;
|
||||||
|
|||||||
@ -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');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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 () => {
|
||||||
|
|||||||
157
src/__tests__/taskInstructionActions.test.ts
Normal file
157
src/__tests__/taskInstructionActions.test.ts
Normal 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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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'", () => {
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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>;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,6 +14,7 @@ export type {
|
|||||||
PartResult,
|
PartResult,
|
||||||
TeamLeaderConfig,
|
TeamLeaderConfig,
|
||||||
PieceRule,
|
PieceRule,
|
||||||
|
MovementProviderOptions,
|
||||||
PieceMovement,
|
PieceMovement,
|
||||||
ArpeggioMovementConfig,
|
ArpeggioMovementConfig,
|
||||||
ArpeggioMergeMovementConfig,
|
ArpeggioMergeMovementConfig,
|
||||||
|
|||||||
@ -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 */
|
||||||
|
|||||||
@ -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,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -37,6 +37,7 @@ export type {
|
|||||||
OutputContractItem,
|
OutputContractItem,
|
||||||
OutputContractEntry,
|
OutputContractEntry,
|
||||||
McpServerConfig,
|
McpServerConfig,
|
||||||
|
MovementProviderOptions,
|
||||||
PieceMovement,
|
PieceMovement,
|
||||||
ArpeggioMovementConfig,
|
ArpeggioMovementConfig,
|
||||||
ArpeggioMergeMovementConfig,
|
ArpeggioMergeMovementConfig,
|
||||||
|
|||||||
@ -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}"`);
|
||||||
|
|||||||
@ -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(),
|
||||||
|
|||||||
@ -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');
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
20
src/features/interactive/actionDispatcher.ts
Normal file
20
src/features/interactive/actionDispatcher.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
/**
|
||||||
|
* Shared dispatcher for post-conversation actions.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface ConversationActionResult<A extends string> {
|
||||||
|
action: A;
|
||||||
|
task: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ConversationActionHandler<A extends string, R> = (
|
||||||
|
result: ConversationActionResult<A>,
|
||||||
|
) => Promise<R> | R;
|
||||||
|
|
||||||
|
export async function dispatchConversationAction<A extends string, R>(
|
||||||
|
result: ConversationActionResult<A>,
|
||||||
|
handlers: Record<A, ConversationActionHandler<A, R>>,
|
||||||
|
): Promise<R> {
|
||||||
|
return handlers[result.action](result);
|
||||||
|
}
|
||||||
|
|
||||||
@ -28,6 +28,7 @@ import {
|
|||||||
type InteractiveModeResult,
|
type 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;
|
||||||
|
|||||||
@ -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';
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
81
src/features/tasks/execute/postExecution.ts
Normal file
81
src/features/tasks/execute/postExecution.ts
Normal 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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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 } : {}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
125
src/features/tasks/execute/taskResultHandler.ts
Normal file
125
src/features/tasks/execute/taskResultHandler.ts
Normal 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)}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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 {
|
||||||
|
|||||||
@ -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') {
|
||||||
|
|||||||
125
src/features/tasks/list/instructMode.ts
Normal file
125
src/features/tasks/list/instructMode.ts
Normal 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 };
|
||||||
|
}
|
||||||
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
29
src/features/tasks/list/taskActionTarget.ts
Normal file
29
src/features/tasks/list/taskActionTarget.ts
Normal 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;
|
||||||
|
}
|
||||||
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
141
src/features/tasks/list/taskBranchLifecycleActions.ts
Normal file
141
src/features/tasks/list/taskBranchLifecycleActions.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
|||||||
74
src/features/tasks/list/taskDiffActions.ts
Normal file
74
src/features/tasks/list/taskDiffActions.ts
Normal 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' },
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
185
src/features/tasks/list/taskInstructionActions.ts
Normal file
185
src/features/tasks/list/taskInstructionActions.ts
Normal 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);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -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',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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). */
|
||||||
|
|||||||
@ -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 */
|
||||||
|
|||||||
@ -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();
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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 */
|
||||||
|
|||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -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;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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(),
|
||||||
|
|||||||
29
src/infra/task/taskDeletionService.ts
Normal file
29
src/infra/task/taskDeletionService.ts
Normal 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)),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
232
src/infra/task/taskLifecycleService.ts
Normal file
232
src/infra/task/taskLifecycleService.ts
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
37
src/infra/task/taskQueryService.ts
Normal file
37
src/infra/task/taskQueryService.ts
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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})"
|
||||||
|
|||||||
@ -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})"
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user