feat: CLIサブコマンド形式への移行と対話式タスク入力モード (#47)
- スラッシュコマンド形式をサブコマンド形式に変更(takt run, takt add 等) - 引数なし takt で対話的にAIとタスク要件を詰めるinteractiveモードを追加 - セッション永続化により takt 再起動後も会話を継続 - 調査用ツール(Read, Glob, Grep, Bash, WebSearch, WebFetch)を許可 - プランニング専用のシステムプロンプトでコード変更を禁止 - executor の buildSdkOptions を未定義値を含めないよう修正(SDK ハング対策) - help/refreshBuiltinコマンドを削除、ejectコマンドを簡素化 - ドキュメント(CLAUDE.md, README, workflows.md)をサブコマンド形式に更新
This commit is contained in:
parent
bcacd7127d
commit
7bac0053ff
29
CLAUDE.md
29
CLAUDE.md
@ -17,20 +17,21 @@ TAKT (Task Agent Koordination Tool) is a multi-agent orchestration system for Cl
|
||||
| `npx vitest run src/__tests__/client.test.ts` | Run single test file |
|
||||
| `npx vitest run -t "pattern"` | Run tests matching pattern |
|
||||
|
||||
## CLI Slash Commands
|
||||
## CLI Subcommands
|
||||
|
||||
| Command | Alias | Description |
|
||||
|---------|-------|-------------|
|
||||
| `takt /run-tasks` | `/run` | Execute all pending tasks from `.takt/tasks/` once |
|
||||
| `takt /watch` | | Watch `.takt/tasks/` and auto-execute tasks (resident process) |
|
||||
| `takt /add-task` | `/add` | Add a new task interactively (YAML format, multiline supported) |
|
||||
| `takt /list-tasks` | `/list` | List task branches (try merge, merge & cleanup, or delete) |
|
||||
| `takt /switch` | `/sw` | Switch workflow interactively |
|
||||
| `takt /clear` | | Clear agent conversation sessions (reset state) |
|
||||
| `takt /eject` | | Copy builtin workflow/agents to `~/.takt/` for customization |
|
||||
| `takt /refresh-builtin` | | Update builtin resources from `resources/` to `~/.takt/` |
|
||||
| `takt /help` | | Show help message |
|
||||
| `takt /config` | | Display current configuration |
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `takt {task}` | Execute task with current workflow |
|
||||
| `takt` | Interactive task input mode |
|
||||
| `takt run` | Execute all pending tasks from `.takt/tasks/` once |
|
||||
| `takt watch` | Watch `.takt/tasks/` and auto-execute tasks (resident process) |
|
||||
| `takt add` | Add a new task via AI conversation |
|
||||
| `takt list` | List task branches (try merge, merge & cleanup, or delete) |
|
||||
| `takt switch` | Switch workflow interactively |
|
||||
| `takt clear` | Clear agent conversation sessions (reset state) |
|
||||
| `takt eject` | Copy builtin workflow/agents to `~/.takt/` for customization |
|
||||
| `takt config` | Configure settings (permission mode) |
|
||||
| `takt --help` | Show help message |
|
||||
|
||||
GitHub issue references: `takt #6` fetches issue #6 and executes it as a task.
|
||||
|
||||
@ -296,7 +297,7 @@ Key constraints:
|
||||
- **Session isolation**: Claude Code sessions are stored per-cwd in `~/.claude/projects/{encoded-path}/`. Sessions from the main project cannot be resumed in a clone. The engine skips session resume when `cwd !== projectCwd`.
|
||||
- **No node_modules**: Clones only contain tracked files. `node_modules/` is absent.
|
||||
- **Dual cwd**: `cwd` = clone path (where agents run), `projectCwd` = project root (where `.takt/` lives). Reports, logs, and session data always write to `projectCwd`.
|
||||
- **List**: Use `takt /list-tasks` to list branches. Instruct action creates a temporary clone for the branch, executes, pushes, then removes the clone.
|
||||
- **List**: Use `takt list` to list branches. Instruct action creates a temporary clone for the branch, executes, pushes, then removes the clone.
|
||||
|
||||
## Error Propagation
|
||||
|
||||
|
||||
68
README.md
68
README.md
@ -30,20 +30,20 @@ takt "Add a login feature"
|
||||
# Run a GitHub issue as a task
|
||||
takt "#6"
|
||||
|
||||
# Add a task to the queue
|
||||
takt /add-task "Fix the login bug"
|
||||
# Add a task via AI conversation
|
||||
takt add
|
||||
|
||||
# Run all pending tasks
|
||||
takt /run-tasks
|
||||
takt run
|
||||
|
||||
# Watch for tasks and auto-execute
|
||||
takt /watch
|
||||
takt watch
|
||||
|
||||
# List task branches (merge or delete)
|
||||
takt /list-tasks
|
||||
takt list
|
||||
|
||||
# Switch workflow
|
||||
takt /switch
|
||||
takt switch
|
||||
```
|
||||
|
||||
### What happens when you run a task
|
||||
@ -87,24 +87,24 @@ Choose `y` to run in a `git clone --shared` isolated environment, keeping your w
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Alias | Description |
|
||||
|---------|-------|-------------|
|
||||
| `takt "task"` | | Execute task with current workflow (session auto-continued) |
|
||||
| `takt "#N"` | | Execute GitHub issue #N as a task |
|
||||
| `takt /run-tasks` | `/run` | Run all pending tasks from `.takt/tasks/` |
|
||||
| `takt /watch` | | Watch `.takt/tasks/` and auto-execute tasks (stays resident) |
|
||||
| `takt /add-task` | `/add` | Add a new task interactively (YAML format, multiline supported) |
|
||||
| `takt /list-tasks` | `/list` | List task branches (try merge, merge & cleanup, or delete) |
|
||||
| `takt /switch` | `/sw` | Switch workflow interactively |
|
||||
| `takt /clear` | | Clear agent conversation sessions |
|
||||
| `takt /eject` | | Copy builtin workflow/agents to `~/.takt/` for customization |
|
||||
| `takt /refresh-builtin` | | Update builtin agents/workflows to latest version |
|
||||
| `takt /config` | | Configure permission mode |
|
||||
| `takt /help` | | Show help |
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `takt "task"` | Execute task with current workflow (session auto-continued) |
|
||||
| `takt "#N"` | Execute GitHub issue #N as a task |
|
||||
| `takt` | Interactive task input mode |
|
||||
| `takt run` | Run all pending tasks from `.takt/tasks/` |
|
||||
| `takt watch` | Watch `.takt/tasks/` and auto-execute tasks (stays resident) |
|
||||
| `takt add` | Add a new task via AI conversation |
|
||||
| `takt list` | List task branches (try merge, merge & cleanup, or delete) |
|
||||
| `takt switch` | Switch workflow interactively |
|
||||
| `takt clear` | Clear agent conversation sessions |
|
||||
| `takt eject` | Copy builtin workflow/agents to `~/.takt/` for customization |
|
||||
| `takt config` | Configure permission mode |
|
||||
| `takt --help` | Show help |
|
||||
|
||||
## Workflows
|
||||
|
||||
TAKT uses YAML-based workflow definitions with rule-based routing. Builtin workflows are embedded in the package; user workflows in `~/.takt/workflows/` take priority. Use `/eject` to copy a builtin to `~/.takt/` for customization.
|
||||
TAKT uses YAML-based workflow definitions with rule-based routing. Builtin workflows are embedded in the package; user workflows in `~/.takt/workflows/` take priority. Use `takt eject` to copy a builtin to `~/.takt/` for customization.
|
||||
|
||||
### Example Workflow
|
||||
|
||||
@ -201,7 +201,7 @@ TAKT ships with several built-in workflows:
|
||||
| `expert-cqrs` | Sequential review with domain experts: CQRS+ES, Frontend, Security, QA reviews with fix loops. |
|
||||
| `magi` | Deliberation system inspired by Evangelion. Three AI personas (MELCHIOR, BALTHASAR, CASPER) analyze and vote. |
|
||||
|
||||
Switch between workflows with `takt /switch`.
|
||||
Switch between workflows with `takt switch`.
|
||||
|
||||
## Built-in Agents
|
||||
|
||||
@ -312,7 +312,7 @@ Create your own workflow by adding YAML files to `~/.takt/workflows/`, or use `/
|
||||
|
||||
```bash
|
||||
# Copy the default workflow to ~/.takt/workflows/ for editing
|
||||
takt /eject default
|
||||
takt eject default
|
||||
```
|
||||
|
||||
```yaml
|
||||
@ -365,19 +365,15 @@ agent: /path/to/custom/agent.md
|
||||
|
||||
TAKT supports batch task processing through task files in `.takt/tasks/`. Both `.yaml`/`.yml` and `.md` file formats are supported.
|
||||
|
||||
#### Adding Tasks with `/add-task`
|
||||
#### Adding Tasks with `takt add`
|
||||
|
||||
```bash
|
||||
# Quick add (no isolation)
|
||||
takt /add-task "Add authentication feature"
|
||||
|
||||
# Add a GitHub issue as a task
|
||||
takt /add-task "#6"
|
||||
|
||||
# Interactive mode (prompts for isolation, branch, workflow options)
|
||||
takt /add-task
|
||||
# Start AI conversation to define and add a task
|
||||
takt add
|
||||
```
|
||||
|
||||
The `takt add` command starts an AI conversation where you discuss and refine your task requirements. After confirming with `/go`, the AI summarizes the conversation and creates a YAML task file with optional worktree/branch/workflow settings.
|
||||
|
||||
#### Task File Formats
|
||||
|
||||
**YAML format** (recommended, supports worktree/branch/workflow options):
|
||||
@ -414,12 +410,12 @@ YAML task files can specify `worktree` to run each task in an isolated `git clon
|
||||
|
||||
> **Note**: The YAML field is named `worktree` for backward compatibility. Internally, `git clone --shared` is used instead of `git worktree` because git worktrees have a `.git` file with `gitdir:` that points back to the main repository, causing Claude Code to recognize the main repo as the project root. Shared clones have an independent `.git` directory that avoids this issue.
|
||||
|
||||
Clones are ephemeral. When a task completes successfully, TAKT automatically commits all changes and pushes the branch to the main repository, then deletes the clone. Use `takt /list-tasks` to list, try-merge, or delete task branches.
|
||||
Clones are ephemeral. When a task completes successfully, TAKT automatically commits all changes and pushes the branch to the main repository, then deletes the clone. Use `takt list` to list, try-merge, or delete task branches.
|
||||
|
||||
#### Running Tasks with `/run-tasks`
|
||||
|
||||
```bash
|
||||
takt /run-tasks
|
||||
takt run
|
||||
```
|
||||
|
||||
- Tasks are executed in alphabetical order (use prefixes like `001-`, `002-` for ordering)
|
||||
@ -429,7 +425,7 @@ takt /run-tasks
|
||||
#### Watching Tasks with `/watch`
|
||||
|
||||
```bash
|
||||
takt /watch
|
||||
takt watch
|
||||
```
|
||||
|
||||
Watch mode polls `.takt/tasks/` for new task files and auto-executes them as they appear. The process stays resident until `Ctrl+C`. This is useful for:
|
||||
@ -440,7 +436,7 @@ Watch mode polls `.takt/tasks/` for new task files and auto-executes them as the
|
||||
#### Listing Task Branches with `/list-tasks`
|
||||
|
||||
```bash
|
||||
takt /list-tasks
|
||||
takt list
|
||||
```
|
||||
|
||||
Lists all `takt/`-prefixed branches with file change counts. For each branch you can:
|
||||
|
||||
@ -26,20 +26,20 @@ takt "ログイン機能を追加して"
|
||||
# GitHub Issueをタスクとして実行
|
||||
takt "#6"
|
||||
|
||||
# タスクをキューに追加
|
||||
takt /add-task "ログインのバグを修正"
|
||||
# AI会話でタスクを追加
|
||||
takt add
|
||||
|
||||
# 保留中のタスクをすべて実行
|
||||
takt /run-tasks
|
||||
takt run
|
||||
|
||||
# タスクを監視して自動実行
|
||||
takt /watch
|
||||
takt watch
|
||||
|
||||
# タスクブランチ一覧(マージ・削除)
|
||||
takt /list-tasks
|
||||
takt list
|
||||
|
||||
# ワークフローを切り替え
|
||||
takt /switch
|
||||
takt switch
|
||||
```
|
||||
|
||||
### タスク実行の流れ
|
||||
@ -83,24 +83,24 @@ Select workflow:
|
||||
|
||||
## コマンド一覧
|
||||
|
||||
| コマンド | エイリアス | 説明 |
|
||||
|---------|-----------|------|
|
||||
| `takt "タスク"` | | 現在のワークフローでタスクを実行(セッション自動継続) |
|
||||
| `takt "#N"` | | GitHub Issue #Nをタスクとして実行 |
|
||||
| `takt /run-tasks` | `/run` | `.takt/tasks/` の保留中タスクをすべて実行 |
|
||||
| `takt /watch` | | `.takt/tasks/` を監視してタスクを自動実行(常駐プロセス) |
|
||||
| `takt /add-task` | `/add` | 新しいタスクを対話的に追加(YAML形式、複数行対応) |
|
||||
| `takt /list-tasks` | `/list` | タスクブランチ一覧(マージ・削除) |
|
||||
| `takt /switch` | `/sw` | ワークフローを対話的に切り替え |
|
||||
| `takt /clear` | | エージェントの会話セッションをクリア |
|
||||
| `takt /eject` | | ビルトインのワークフロー/エージェントを`~/.takt/`にコピーしてカスタマイズ |
|
||||
| `takt /refresh-builtin` | | ビルトインのエージェント/ワークフローを最新版に更新 |
|
||||
| `takt /config` | | パーミッションモードを設定 |
|
||||
| `takt /help` | | ヘルプを表示 |
|
||||
| コマンド | 説明 |
|
||||
|---------|------|
|
||||
| `takt "タスク"` | 現在のワークフローでタスクを実行(セッション自動継続) |
|
||||
| `takt "#N"` | GitHub Issue #Nをタスクとして実行 |
|
||||
| `takt` | 対話式タスク入力モード |
|
||||
| `takt run` | `.takt/tasks/` の保留中タスクをすべて実行 |
|
||||
| `takt watch` | `.takt/tasks/` を監視してタスクを自動実行(常駐プロセス) |
|
||||
| `takt add` | AI会話で新しいタスクを追加 |
|
||||
| `takt list` | タスクブランチ一覧(マージ・削除) |
|
||||
| `takt switch` | ワークフローを対話的に切り替え |
|
||||
| `takt clear` | エージェントの会話セッションをクリア |
|
||||
| `takt eject` | ビルトインのワークフロー/エージェントを`~/.takt/`にコピーしてカスタマイズ |
|
||||
| `takt config` | パーミッションモードを設定 |
|
||||
| `takt --help` | ヘルプを表示 |
|
||||
|
||||
## ワークフロー
|
||||
|
||||
TAKTはYAMLベースのワークフロー定義とルールベースルーティングを使用します。ビルトインワークフローはパッケージに埋め込まれており、`~/.takt/workflows/` のユーザーワークフローが優先されます。`/eject` でビルトインを`~/.takt/`にコピーしてカスタマイズできます。
|
||||
TAKTはYAMLベースのワークフロー定義とルールベースルーティングを使用します。ビルトインワークフローはパッケージに埋め込まれており、`~/.takt/workflows/` のユーザーワークフローが優先されます。`takt eject` でビルトインを`~/.takt/`にコピーしてカスタマイズできます。
|
||||
|
||||
### ワークフローの例
|
||||
|
||||
@ -197,7 +197,7 @@ TAKTには複数のビルトインワークフローが同梱されています:
|
||||
| `expert-cqrs` | ドメインエキスパートによる逐次レビュー: CQRS+ES、フロントエンド、セキュリティ、QAレビューと修正ループ。 |
|
||||
| `magi` | エヴァンゲリオンにインスパイアされた審議システム。3つのAIペルソナ(MELCHIOR、BALTHASAR、CASPER)が分析し投票。 |
|
||||
|
||||
`takt /switch` でワークフローを切り替えられます。
|
||||
`takt switch` でワークフローを切り替えられます。
|
||||
|
||||
## ビルトインエージェント
|
||||
|
||||
@ -296,19 +296,15 @@ trusted_directories:
|
||||
|
||||
TAKTは`.takt/tasks/`内のタスクファイルによるバッチ処理をサポートしています。`.yaml`/`.yml`と`.md`の両方のファイル形式に対応しています。
|
||||
|
||||
#### `/add-task` でタスクを追加
|
||||
#### `takt add` でタスクを追加
|
||||
|
||||
```bash
|
||||
# クイック追加(隔離なし)
|
||||
takt /add-task "認証機能を追加"
|
||||
|
||||
# GitHub Issueをタスクとして追加
|
||||
takt /add-task "#6"
|
||||
|
||||
# 対話モード(隔離実行、ブランチ、ワークフローオプションを指定可能)
|
||||
takt /add-task
|
||||
# AI会話でタスクの要件を詰めてからタスクを追加
|
||||
takt add
|
||||
```
|
||||
|
||||
`takt add` はAI会話を開始し、タスクの要件を詰めます。`/go` で確定すると、AIが会話を要約してYAMLタスクファイルを作成します。worktree/branch/workflowの設定も対話的に行えます。
|
||||
|
||||
#### タスクファイルの形式
|
||||
|
||||
**YAML形式**(推奨、worktree/branch/workflowオプション対応):
|
||||
@ -345,12 +341,12 @@ YAMLタスクファイルで`worktree`を指定すると、各タスクを`git c
|
||||
|
||||
> **Note**: YAMLフィールド名は後方互換のため`worktree`のままです。内部的には`git worktree`ではなく`git clone --shared`を使用しています。git worktreeの`.git`ファイルには`gitdir:`でメインリポジトリへのパスが記載されており、Claude Codeがそれを辿ってメインリポジトリをプロジェクトルートと認識してしまうためです。共有クローンは独立した`.git`ディレクトリを持つため、この問題が発生しません。
|
||||
|
||||
クローンは使い捨てです。タスク完了後に自動的にコミット+プッシュし、クローンを削除します。ブランチが唯一の永続的な成果物です。`takt /list-tasks`でブランチの一覧表示・マージ・削除ができます。
|
||||
クローンは使い捨てです。タスク完了後に自動的にコミット+プッシュし、クローンを削除します。ブランチが唯一の永続的な成果物です。`takt list`でブランチの一覧表示・マージ・削除ができます。
|
||||
|
||||
#### `/run-tasks` でタスクを実行
|
||||
|
||||
```bash
|
||||
takt /run-tasks
|
||||
takt run
|
||||
```
|
||||
|
||||
- タスクはアルファベット順に実行されます(`001-`、`002-`のようなプレフィックスで順序を制御)
|
||||
@ -360,7 +356,7 @@ takt /run-tasks
|
||||
#### `/watch` でタスクを監視
|
||||
|
||||
```bash
|
||||
takt /watch
|
||||
takt watch
|
||||
```
|
||||
|
||||
ウォッチモードは`.takt/tasks/`をポーリングし、新しいタスクファイルが現れると自動実行します。`Ctrl+C`で停止する常駐プロセスです。以下のような場合に便利です:
|
||||
@ -371,7 +367,7 @@ takt /watch
|
||||
#### `/list-tasks` でタスクブランチを一覧表示
|
||||
|
||||
```bash
|
||||
takt /list-tasks
|
||||
takt list
|
||||
```
|
||||
|
||||
`takt/`プレフィックスのブランチをファイル変更数とともに一覧表示します。各ブランチに対して以下の操作が可能です:
|
||||
@ -398,7 +394,7 @@ TAKTはセッションログをNDJSON(`.jsonl`)形式で`.takt/logs/`に書
|
||||
|
||||
```bash
|
||||
# defaultワークフローを~/.takt/workflows/にコピーして編集
|
||||
takt /eject default
|
||||
takt eject default
|
||||
```
|
||||
|
||||
```yaml
|
||||
|
||||
@ -13,7 +13,7 @@ A workflow is a YAML file that defines a sequence of steps executed by AI agents
|
||||
|
||||
- Builtin workflows are embedded in the npm package (`dist/resources/`)
|
||||
- `~/.takt/workflows/` — User workflows (override builtins with the same name)
|
||||
- Use `takt /eject <workflow>` to copy a builtin to `~/.takt/workflows/` for customization
|
||||
- Use `takt eject <workflow>` to copy a builtin to `~/.takt/workflows/` for customization
|
||||
|
||||
## Workflow Schema
|
||||
|
||||
|
||||
@ -8,9 +8,20 @@ import * as path from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
|
||||
// Mock dependencies before importing the module under test
|
||||
vi.mock('../commands/interactive.js', () => ({
|
||||
interactiveMode: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../providers/index.js', () => ({
|
||||
getProvider: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../config/globalConfig.js', () => ({
|
||||
loadGlobalConfig: vi.fn(() => ({ provider: 'claude' })),
|
||||
}));
|
||||
|
||||
vi.mock('../prompt/index.js', () => ({
|
||||
promptInput: vi.fn(),
|
||||
promptMultilineInput: vi.fn(),
|
||||
confirm: vi.fn(),
|
||||
selectOption: vi.fn(),
|
||||
}));
|
||||
@ -40,120 +51,157 @@ vi.mock('../config/paths.js', () => ({
|
||||
getCurrentWorkflow: vi.fn(() => 'default'),
|
||||
}));
|
||||
|
||||
import { promptMultilineInput, confirm, selectOption } from '../prompt/index.js';
|
||||
vi.mock('../github/issue.js', () => ({
|
||||
isIssueReference: vi.fn((s: string) => /^#\d+$/.test(s)),
|
||||
resolveIssueTask: vi.fn(),
|
||||
}));
|
||||
|
||||
import { interactiveMode } from '../commands/interactive.js';
|
||||
import { getProvider } from '../providers/index.js';
|
||||
import { promptInput, confirm, selectOption } from '../prompt/index.js';
|
||||
import { summarizeTaskName } from '../task/summarize.js';
|
||||
import { listWorkflows } from '../config/workflowLoader.js';
|
||||
import { addTask } from '../commands/addTask.js';
|
||||
import { resolveIssueTask } from '../github/issue.js';
|
||||
import { addTask, summarizeConversation } from '../commands/addTask.js';
|
||||
|
||||
const mockPromptMultilineInput = vi.mocked(promptMultilineInput);
|
||||
const mockResolveIssueTask = vi.mocked(resolveIssueTask);
|
||||
|
||||
const mockInteractiveMode = vi.mocked(interactiveMode);
|
||||
const mockGetProvider = vi.mocked(getProvider);
|
||||
const mockPromptInput = vi.mocked(promptInput);
|
||||
const mockConfirm = vi.mocked(confirm);
|
||||
const mockSelectOption = vi.mocked(selectOption);
|
||||
const mockSummarizeTaskName = vi.mocked(summarizeTaskName);
|
||||
const mockListWorkflows = vi.mocked(listWorkflows);
|
||||
|
||||
/** Helper: set up mocks for the full happy path */
|
||||
function setupFullFlowMocks(overrides?: {
|
||||
conversationTask?: string;
|
||||
summaryContent?: string;
|
||||
slug?: string;
|
||||
}) {
|
||||
const task = overrides?.conversationTask ?? 'User: 認証機能を追加したい\n\nAssistant: 了解です。';
|
||||
const summary = overrides?.summaryContent ?? '# 認証機能追加\nJWT認証を実装する';
|
||||
const slug = overrides?.slug ?? 'add-auth';
|
||||
|
||||
mockInteractiveMode.mockResolvedValue({ confirmed: true, task });
|
||||
|
||||
const mockProviderCall = vi.fn().mockResolvedValue({ content: summary });
|
||||
mockGetProvider.mockReturnValue({ call: mockProviderCall } as any);
|
||||
|
||||
mockSummarizeTaskName.mockResolvedValue(slug);
|
||||
mockConfirm.mockResolvedValue(false);
|
||||
mockListWorkflows.mockReturnValue([]);
|
||||
|
||||
return { mockProviderCall };
|
||||
}
|
||||
|
||||
let testDir: string;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
// Create temporary test directory
|
||||
testDir = fs.mkdtempSync(path.join(tmpdir(), 'takt-test-'));
|
||||
|
||||
// Default mock setup
|
||||
mockListWorkflows.mockReturnValue([]);
|
||||
mockConfirm.mockResolvedValue(false);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Cleanup test directory
|
||||
if (testDir && fs.existsSync(testDir)) {
|
||||
fs.rmSync(testDir, { recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe('addTask', () => {
|
||||
it('should create task file with AI-generated slug for argument mode', async () => {
|
||||
// Given: Task content provided as argument
|
||||
mockSummarizeTaskName.mockResolvedValue('add-auth');
|
||||
mockConfirm.mockResolvedValue(false);
|
||||
it('should cancel when interactive mode is not confirmed', async () => {
|
||||
// Given: user cancels interactive mode
|
||||
mockInteractiveMode.mockResolvedValue({ confirmed: false, task: '' });
|
||||
|
||||
// When
|
||||
await addTask(testDir, ['認証機能を追加する']);
|
||||
await addTask(testDir);
|
||||
|
||||
// Then
|
||||
// Then: no task file created
|
||||
const tasksDir = path.join(testDir, '.takt', 'tasks');
|
||||
const files = fs.existsSync(tasksDir) ? fs.readdirSync(tasksDir) : [];
|
||||
expect(files.length).toBe(0);
|
||||
expect(mockGetProvider).not.toHaveBeenCalled();
|
||||
expect(mockSummarizeTaskName).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('should create task file with AI-summarized content', async () => {
|
||||
// Given: full flow setup
|
||||
setupFullFlowMocks();
|
||||
|
||||
// When
|
||||
await addTask(testDir);
|
||||
|
||||
// Then: task file created with summarized content
|
||||
const tasksDir = path.join(testDir, '.takt', 'tasks');
|
||||
const taskFile = path.join(tasksDir, 'add-auth.yaml');
|
||||
expect(fs.existsSync(taskFile)).toBe(true);
|
||||
|
||||
const content = fs.readFileSync(taskFile, 'utf-8');
|
||||
expect(content).toContain('task: 認証機能を追加する');
|
||||
expect(content).toContain('# 認証機能追加');
|
||||
expect(content).toContain('JWT認証を実装する');
|
||||
});
|
||||
|
||||
it('should use AI-summarized slug for Japanese task content', async () => {
|
||||
// Given: Japanese task
|
||||
mockSummarizeTaskName.mockResolvedValue('fix-login-bug');
|
||||
mockConfirm.mockResolvedValue(false);
|
||||
|
||||
// When
|
||||
await addTask(testDir, ['ログインバグを修正する']);
|
||||
|
||||
// Then
|
||||
expect(mockSummarizeTaskName).toHaveBeenCalledWith('ログインバグを修正する', { cwd: testDir });
|
||||
|
||||
const taskFile = path.join(testDir, '.takt', 'tasks', 'fix-login-bug.yaml');
|
||||
expect(fs.existsSync(taskFile)).toBe(true);
|
||||
it('should summarize conversation via provider.call', async () => {
|
||||
// Given
|
||||
const { mockProviderCall } = setupFullFlowMocks({
|
||||
conversationTask: 'User: バグ修正して\n\nAssistant: どのバグですか?',
|
||||
});
|
||||
|
||||
it('should handle multiline task content using first line for filename', async () => {
|
||||
// Given: Multiline task content in interactive mode
|
||||
mockPromptMultilineInput.mockResolvedValue('First line task\nSecond line details');
|
||||
mockSummarizeTaskName.mockResolvedValue('first-line-task');
|
||||
mockConfirm.mockResolvedValue(false);
|
||||
|
||||
// When
|
||||
await addTask(testDir, []);
|
||||
await addTask(testDir);
|
||||
|
||||
// Then
|
||||
expect(mockSummarizeTaskName).toHaveBeenCalledWith('First line task', { cwd: testDir });
|
||||
// Then: provider.call was called with conversation text
|
||||
expect(mockProviderCall).toHaveBeenCalledWith(
|
||||
'task-summarizer',
|
||||
'User: バグ修正して\n\nAssistant: どのバグですか?',
|
||||
expect.objectContaining({
|
||||
cwd: testDir,
|
||||
maxTurns: 1,
|
||||
allowedTools: [],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should use fallback filename when AI returns empty', async () => {
|
||||
// Given: AI returns empty slug (which defaults to 'task' in summarizeTaskName)
|
||||
mockSummarizeTaskName.mockResolvedValue('task');
|
||||
mockConfirm.mockResolvedValue(false);
|
||||
it('should use first line of summary for filename generation', async () => {
|
||||
// Given: summary with multiple lines
|
||||
setupFullFlowMocks({
|
||||
summaryContent: 'First line summary\nSecond line details',
|
||||
slug: 'first-line',
|
||||
});
|
||||
|
||||
// When
|
||||
await addTask(testDir, ['test']);
|
||||
await addTask(testDir);
|
||||
|
||||
// Then
|
||||
const taskFile = path.join(testDir, '.takt', 'tasks', 'task.yaml');
|
||||
expect(fs.existsSync(taskFile)).toBe(true);
|
||||
// Then: summarizeTaskName receives only the first line
|
||||
expect(mockSummarizeTaskName).toHaveBeenCalledWith('First line summary', { cwd: testDir });
|
||||
});
|
||||
|
||||
it('should append counter for duplicate filenames', async () => {
|
||||
// Given: First task creates 'my-task.yaml'
|
||||
mockSummarizeTaskName.mockResolvedValue('my-task');
|
||||
mockConfirm.mockResolvedValue(false);
|
||||
// Given: first task creates 'my-task.yaml'
|
||||
setupFullFlowMocks({ slug: 'my-task' });
|
||||
await addTask(testDir);
|
||||
|
||||
// When: Create first task
|
||||
await addTask(testDir, ['First task']);
|
||||
// When: create second task with same slug
|
||||
setupFullFlowMocks({ slug: 'my-task' });
|
||||
await addTask(testDir);
|
||||
|
||||
// And: Create second task with same slug
|
||||
await addTask(testDir, ['Second task']);
|
||||
|
||||
// Then: Second file should have counter
|
||||
// Then: second file has counter
|
||||
const tasksDir = path.join(testDir, '.takt', 'tasks');
|
||||
expect(fs.existsSync(path.join(tasksDir, 'my-task.yaml'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(tasksDir, 'my-task-1.yaml'))).toBe(true);
|
||||
});
|
||||
|
||||
it('should include worktree option in task file when confirmed', async () => {
|
||||
// Given: User confirms worktree creation
|
||||
mockSummarizeTaskName.mockResolvedValue('with-worktree');
|
||||
it('should include worktree option when confirmed', async () => {
|
||||
// Given: user confirms worktree
|
||||
setupFullFlowMocks({ slug: 'with-worktree' });
|
||||
mockConfirm.mockResolvedValue(true);
|
||||
mockPromptInput.mockResolvedValue('');
|
||||
|
||||
// When
|
||||
await addTask(testDir, ['Task with worktree']);
|
||||
await addTask(testDir);
|
||||
|
||||
// Then
|
||||
const taskFile = path.join(testDir, '.takt', 'tasks', 'with-worktree.yaml');
|
||||
@ -161,33 +209,184 @@ describe('addTask', () => {
|
||||
expect(content).toContain('worktree: true');
|
||||
});
|
||||
|
||||
it('should cancel when interactive mode returns null', async () => {
|
||||
// Given: User cancels multiline input
|
||||
mockPromptMultilineInput.mockResolvedValue(null);
|
||||
it('should include custom worktree path when provided', async () => {
|
||||
// Given: user provides custom worktree path
|
||||
setupFullFlowMocks({ slug: 'custom-path' });
|
||||
mockConfirm.mockResolvedValue(true);
|
||||
mockPromptInput
|
||||
.mockResolvedValueOnce('/custom/path')
|
||||
.mockResolvedValueOnce('');
|
||||
|
||||
// When
|
||||
await addTask(testDir, []);
|
||||
await addTask(testDir);
|
||||
|
||||
// Then
|
||||
const tasksDir = path.join(testDir, '.takt', 'tasks');
|
||||
const files = fs.existsSync(tasksDir) ? fs.readdirSync(tasksDir) : [];
|
||||
expect(files.length).toBe(0);
|
||||
expect(mockSummarizeTaskName).not.toHaveBeenCalled();
|
||||
const taskFile = path.join(testDir, '.takt', 'tasks', 'custom-path.yaml');
|
||||
const content = fs.readFileSync(taskFile, 'utf-8');
|
||||
expect(content).toContain('worktree: /custom/path');
|
||||
});
|
||||
|
||||
it('should include branch when provided', async () => {
|
||||
// Given: user provides custom branch
|
||||
setupFullFlowMocks({ slug: 'with-branch' });
|
||||
mockConfirm.mockResolvedValue(true);
|
||||
mockPromptInput
|
||||
.mockResolvedValueOnce('')
|
||||
.mockResolvedValueOnce('feat/my-branch');
|
||||
|
||||
// When
|
||||
await addTask(testDir);
|
||||
|
||||
// Then
|
||||
const taskFile = path.join(testDir, '.takt', 'tasks', 'with-branch.yaml');
|
||||
const content = fs.readFileSync(taskFile, 'utf-8');
|
||||
expect(content).toContain('branch: feat/my-branch');
|
||||
});
|
||||
|
||||
it('should include workflow selection in task file', async () => {
|
||||
// Given: Multiple workflows available
|
||||
// Given: multiple workflows available
|
||||
setupFullFlowMocks({ slug: 'with-workflow' });
|
||||
mockListWorkflows.mockReturnValue(['default', 'review']);
|
||||
mockSummarizeTaskName.mockResolvedValue('with-workflow');
|
||||
mockConfirm.mockResolvedValue(false);
|
||||
mockSelectOption.mockResolvedValue('review');
|
||||
|
||||
// When
|
||||
await addTask(testDir, ['Task with workflow']);
|
||||
await addTask(testDir);
|
||||
|
||||
// Then
|
||||
const taskFile = path.join(testDir, '.takt', 'tasks', 'with-workflow.yaml');
|
||||
const content = fs.readFileSync(taskFile, 'utf-8');
|
||||
expect(content).toContain('workflow: review');
|
||||
});
|
||||
|
||||
it('should cancel when workflow selection returns null', async () => {
|
||||
// Given: workflows available but user cancels selection
|
||||
setupFullFlowMocks({ slug: 'cancelled' });
|
||||
mockListWorkflows.mockReturnValue(['default', 'review']);
|
||||
mockConfirm.mockResolvedValue(false);
|
||||
mockSelectOption.mockResolvedValue(null);
|
||||
|
||||
// When
|
||||
await addTask(testDir);
|
||||
|
||||
// Then: no task file created (cancelled at workflow selection)
|
||||
const tasksDir = path.join(testDir, '.takt', 'tasks');
|
||||
const files = fs.readdirSync(tasksDir);
|
||||
expect(files.length).toBe(0);
|
||||
});
|
||||
|
||||
it('should not include workflow when current workflow is selected', async () => {
|
||||
// Given: current workflow selected (no need to record it)
|
||||
setupFullFlowMocks({ slug: 'default-wf' });
|
||||
mockListWorkflows.mockReturnValue(['default', 'review']);
|
||||
mockConfirm.mockResolvedValue(false);
|
||||
mockSelectOption.mockResolvedValue('default');
|
||||
|
||||
// When
|
||||
await addTask(testDir);
|
||||
|
||||
// Then: workflow field should not be in the YAML
|
||||
const taskFile = path.join(testDir, '.takt', 'tasks', 'default-wf.yaml');
|
||||
const content = fs.readFileSync(taskFile, 'utf-8');
|
||||
expect(content).not.toContain('workflow:');
|
||||
});
|
||||
|
||||
it('should fetch issue and summarize with AI when given issue reference', async () => {
|
||||
// Given: issue reference "#99"
|
||||
const issueText = 'Issue #99: Fix login timeout\n\nThe login page times out after 30 seconds.';
|
||||
const summarized = '# ログインタイムアウト修正\nログインページの30秒タイムアウトを修正する';
|
||||
mockResolveIssueTask.mockReturnValue(issueText);
|
||||
|
||||
const mockProviderCall = vi.fn().mockResolvedValue({ content: summarized });
|
||||
mockGetProvider.mockReturnValue({ call: mockProviderCall } as any);
|
||||
|
||||
mockSummarizeTaskName.mockResolvedValue('fix-login-timeout');
|
||||
mockConfirm.mockResolvedValue(false);
|
||||
mockListWorkflows.mockReturnValue([]);
|
||||
|
||||
// When
|
||||
await addTask(testDir, '#99');
|
||||
|
||||
// Then: interactiveMode should NOT be called
|
||||
expect(mockInteractiveMode).not.toHaveBeenCalled();
|
||||
|
||||
// Then: resolveIssueTask was called
|
||||
expect(mockResolveIssueTask).toHaveBeenCalledWith('#99');
|
||||
|
||||
// Then: summarizeConversation was called with issue text
|
||||
expect(mockProviderCall).toHaveBeenCalledWith(
|
||||
'task-summarizer',
|
||||
issueText,
|
||||
expect.objectContaining({
|
||||
cwd: testDir,
|
||||
maxTurns: 1,
|
||||
allowedTools: [],
|
||||
}),
|
||||
);
|
||||
|
||||
// Then: task file created with summarized content
|
||||
const taskFile = path.join(testDir, '.takt', 'tasks', 'fix-login-timeout.yaml');
|
||||
expect(fs.existsSync(taskFile)).toBe(true);
|
||||
const content = fs.readFileSync(taskFile, 'utf-8');
|
||||
expect(content).toContain('ログインタイムアウト修正');
|
||||
});
|
||||
|
||||
it('should proceed to worktree/workflow settings after issue summarization', async () => {
|
||||
// Given: issue with worktree enabled
|
||||
mockResolveIssueTask.mockReturnValue('Issue text');
|
||||
const mockProviderCall = vi.fn().mockResolvedValue({ content: 'Summarized issue' });
|
||||
mockGetProvider.mockReturnValue({ call: mockProviderCall } as any);
|
||||
mockSummarizeTaskName.mockResolvedValue('issue-task');
|
||||
mockConfirm.mockResolvedValue(true);
|
||||
mockPromptInput.mockResolvedValue('');
|
||||
mockListWorkflows.mockReturnValue([]);
|
||||
|
||||
// When
|
||||
await addTask(testDir, '#42');
|
||||
|
||||
// Then: worktree settings applied
|
||||
const taskFile = path.join(testDir, '.takt', 'tasks', 'issue-task.yaml');
|
||||
const content = fs.readFileSync(taskFile, 'utf-8');
|
||||
expect(content).toContain('worktree: true');
|
||||
});
|
||||
|
||||
it('should handle GitHub API failure gracefully for issue reference', async () => {
|
||||
// Given: resolveIssueTask throws
|
||||
mockResolveIssueTask.mockImplementation(() => {
|
||||
throw new Error('GitHub API rate limit exceeded');
|
||||
});
|
||||
|
||||
// When
|
||||
await addTask(testDir, '#99');
|
||||
|
||||
// Then: no task file created, no crash
|
||||
const tasksDir = path.join(testDir, '.takt', 'tasks');
|
||||
const files = fs.readdirSync(tasksDir);
|
||||
expect(files.length).toBe(0);
|
||||
expect(mockGetProvider).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('summarizeConversation', () => {
|
||||
it('should call provider with summarize system prompt', async () => {
|
||||
// Given
|
||||
const mockCall = vi.fn().mockResolvedValue({ content: 'Summary text' });
|
||||
mockGetProvider.mockReturnValue({ call: mockCall } as any);
|
||||
|
||||
// When
|
||||
const result = await summarizeConversation('/project', 'conversation text');
|
||||
|
||||
// Then
|
||||
expect(result).toBe('Summary text');
|
||||
expect(mockCall).toHaveBeenCalledWith(
|
||||
'task-summarizer',
|
||||
'conversation text',
|
||||
expect.objectContaining({
|
||||
cwd: '/project',
|
||||
maxTurns: 1,
|
||||
allowedTools: [],
|
||||
systemPrompt: expect.stringContaining('会話履歴からタスクの要件をまとめてください'),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@ -59,13 +59,12 @@ vi.mock('../config/paths.js', () => ({
|
||||
vi.mock('../commands/index.js', () => ({
|
||||
executeTask: vi.fn(),
|
||||
runAllTasks: vi.fn(),
|
||||
showHelp: vi.fn(),
|
||||
switchWorkflow: vi.fn(),
|
||||
switchConfig: vi.fn(),
|
||||
addTask: vi.fn(),
|
||||
refreshBuiltin: vi.fn(),
|
||||
watchTasks: vi.fn(),
|
||||
listTasks: vi.fn(),
|
||||
interactiveMode: vi.fn(() => Promise.resolve({ confirmed: false, task: '' })),
|
||||
}));
|
||||
|
||||
vi.mock('../config/workflowLoader.js', () => ({
|
||||
@ -76,6 +75,15 @@ vi.mock('../constants.js', () => ({
|
||||
DEFAULT_WORKFLOW_NAME: 'default',
|
||||
}));
|
||||
|
||||
vi.mock('../github/issue.js', () => ({
|
||||
isIssueReference: vi.fn((s: string) => /^#\d+$/.test(s)),
|
||||
resolveIssueTask: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../utils/updateNotifier.js', () => ({
|
||||
checkForUpdates: vi.fn(),
|
||||
}));
|
||||
|
||||
import { confirm } from '../prompt/index.js';
|
||||
import { createSharedClone } from '../task/clone.js';
|
||||
import { summarizeTaskName } from '../task/summarize.js';
|
||||
@ -193,3 +201,4 @@ describe('confirmAndCreateWorktree', () => {
|
||||
expect(mockInfo).toHaveBeenCalledWith('Generating branch name...');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
264
src/__tests__/interactive.test.ts
Normal file
264
src/__tests__/interactive.test.ts
Normal file
@ -0,0 +1,264 @@
|
||||
/**
|
||||
* Tests for interactive mode
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
vi.mock('../config/globalConfig.js', () => ({
|
||||
loadGlobalConfig: vi.fn(() => ({ provider: 'mock', language: 'en' })),
|
||||
}));
|
||||
|
||||
vi.mock('../providers/index.js', () => ({
|
||||
getProvider: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../utils/debug.js', () => ({
|
||||
createLogger: () => ({
|
||||
info: vi.fn(),
|
||||
debug: vi.fn(),
|
||||
error: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('../config/paths.js', () => ({
|
||||
loadAgentSessions: vi.fn(() => ({})),
|
||||
updateAgentSession: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock('../utils/ui.js', () => ({
|
||||
info: vi.fn(),
|
||||
StreamDisplay: vi.fn().mockImplementation(() => ({
|
||||
createHandler: vi.fn(() => vi.fn()),
|
||||
flush: vi.fn(),
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock readline to simulate user input
|
||||
vi.mock('node:readline', () => ({
|
||||
createInterface: vi.fn(),
|
||||
}));
|
||||
|
||||
import { createInterface } from 'node:readline';
|
||||
import { getProvider } from '../providers/index.js';
|
||||
import { interactiveMode } from '../commands/interactive.js';
|
||||
|
||||
const mockGetProvider = vi.mocked(getProvider);
|
||||
const mockCreateInterface = vi.mocked(createInterface);
|
||||
|
||||
/** Helper to set up a sequence of readline inputs */
|
||||
function setupInputSequence(inputs: (string | null)[]): void {
|
||||
let callIndex = 0;
|
||||
|
||||
mockCreateInterface.mockImplementation(() => {
|
||||
const input = callIndex < inputs.length ? inputs[callIndex] : null;
|
||||
callIndex++;
|
||||
|
||||
const listeners: Record<string, ((...args: unknown[]) => void)[]> = {};
|
||||
|
||||
const rlMock = {
|
||||
question: vi.fn((_prompt: string, callback: (answer: string) => void) => {
|
||||
if (input === null) {
|
||||
// Simulate EOF (Ctrl+D) — emit close event asynchronously
|
||||
// so that the on('close') listener is registered first
|
||||
queueMicrotask(() => {
|
||||
const closeListeners = listeners['close'] || [];
|
||||
for (const listener of closeListeners) {
|
||||
listener();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
callback(input);
|
||||
}
|
||||
}),
|
||||
close: vi.fn(),
|
||||
on: vi.fn((event: string, listener: (...args: unknown[]) => void) => {
|
||||
if (!listeners[event]) {
|
||||
listeners[event] = [];
|
||||
}
|
||||
listeners[event]!.push(listener);
|
||||
return rlMock;
|
||||
}),
|
||||
} as unknown as ReturnType<typeof createInterface>;
|
||||
|
||||
return rlMock;
|
||||
});
|
||||
}
|
||||
|
||||
/** Create a mock provider that returns given responses */
|
||||
function setupMockProvider(responses: string[]): void {
|
||||
let callIndex = 0;
|
||||
const mockProvider = {
|
||||
call: vi.fn(async () => {
|
||||
const content = callIndex < responses.length ? responses[callIndex] : 'AI response';
|
||||
callIndex++;
|
||||
return {
|
||||
agent: 'interactive',
|
||||
status: 'done' as const,
|
||||
content: content!,
|
||||
timestamp: new Date(),
|
||||
};
|
||||
}),
|
||||
callCustom: vi.fn(),
|
||||
};
|
||||
mockGetProvider.mockReturnValue(mockProvider);
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('interactiveMode', () => {
|
||||
it('should return confirmed=false when user types /cancel', async () => {
|
||||
// Given
|
||||
setupInputSequence(['/cancel']);
|
||||
setupMockProvider([]);
|
||||
|
||||
// When
|
||||
const result = await interactiveMode('/project');
|
||||
|
||||
// Then
|
||||
expect(result.confirmed).toBe(false);
|
||||
expect(result.task).toBe('');
|
||||
});
|
||||
|
||||
it('should return confirmed=false on EOF (Ctrl+D)', async () => {
|
||||
// Given
|
||||
setupInputSequence([null]);
|
||||
setupMockProvider([]);
|
||||
|
||||
// When
|
||||
const result = await interactiveMode('/project');
|
||||
|
||||
// Then
|
||||
expect(result.confirmed).toBe(false);
|
||||
});
|
||||
|
||||
it('should call provider with allowed tools for codebase exploration', async () => {
|
||||
// Given
|
||||
setupInputSequence(['fix the login bug', '/go']);
|
||||
setupMockProvider(['What kind of login bug?']);
|
||||
|
||||
// When
|
||||
await interactiveMode('/project');
|
||||
|
||||
// Then
|
||||
const mockProvider = mockGetProvider.mock.results[0]!.value as { call: ReturnType<typeof vi.fn> };
|
||||
expect(mockProvider.call).toHaveBeenCalledWith(
|
||||
'interactive',
|
||||
expect.any(String),
|
||||
expect.objectContaining({
|
||||
cwd: '/project',
|
||||
allowedTools: ['Read', 'Glob', 'Grep', 'Bash', 'WebSearch', 'WebFetch'],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('should return confirmed=true with task on /go after conversation', async () => {
|
||||
// Given
|
||||
setupInputSequence(['add auth feature', '/go']);
|
||||
setupMockProvider(['What kind of authentication?']);
|
||||
|
||||
// When
|
||||
const result = await interactiveMode('/project');
|
||||
|
||||
// Then
|
||||
expect(result.confirmed).toBe(true);
|
||||
expect(result.task).toContain('add auth feature');
|
||||
expect(result.task).toContain('What kind of authentication?');
|
||||
});
|
||||
|
||||
it('should reject /go with no prior conversation', async () => {
|
||||
// Given: /go immediately, then /cancel to exit
|
||||
setupInputSequence(['/go', '/cancel']);
|
||||
setupMockProvider([]);
|
||||
|
||||
// When
|
||||
const result = await interactiveMode('/project');
|
||||
|
||||
// Then: should not confirm (fell through to /cancel)
|
||||
expect(result.confirmed).toBe(false);
|
||||
});
|
||||
|
||||
it('should skip empty input', async () => {
|
||||
// Given: empty line, then actual input, then /go
|
||||
setupInputSequence(['', 'do something', '/go']);
|
||||
setupMockProvider(['Sure, what exactly?']);
|
||||
|
||||
// When
|
||||
const result = await interactiveMode('/project');
|
||||
|
||||
// Then
|
||||
expect(result.confirmed).toBe(true);
|
||||
const mockProvider = mockGetProvider.mock.results[0]!.value as { call: ReturnType<typeof vi.fn> };
|
||||
expect(mockProvider.call).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should accumulate conversation history across multiple turns', async () => {
|
||||
// Given: two user messages before /go
|
||||
setupInputSequence(['first message', 'second message', '/go']);
|
||||
setupMockProvider(['response to first', 'response to second']);
|
||||
|
||||
// When
|
||||
const result = await interactiveMode('/project');
|
||||
|
||||
// Then: task should contain all messages
|
||||
expect(result.confirmed).toBe(true);
|
||||
expect(result.task).toContain('first message');
|
||||
expect(result.task).toContain('response to first');
|
||||
expect(result.task).toContain('second message');
|
||||
expect(result.task).toContain('response to second');
|
||||
});
|
||||
|
||||
it('should send only current input per turn (session handles history)', async () => {
|
||||
// Given
|
||||
setupInputSequence(['first msg', 'second msg', '/go']);
|
||||
setupMockProvider(['AI reply 1', 'AI reply 2']);
|
||||
|
||||
// When
|
||||
await interactiveMode('/project');
|
||||
|
||||
// Then: each call receives only the current user input (session maintains context)
|
||||
const mockProvider = mockGetProvider.mock.results[0]!.value as { call: ReturnType<typeof vi.fn> };
|
||||
expect(mockProvider.call.mock.calls[0]?.[1]).toBe('first msg');
|
||||
expect(mockProvider.call.mock.calls[1]?.[1]).toBe('second msg');
|
||||
});
|
||||
|
||||
it('should process initialInput as first message before entering loop', async () => {
|
||||
// Given: initialInput provided, then user types /go
|
||||
setupInputSequence(['/go']);
|
||||
setupMockProvider(['What do you mean by "a"?']);
|
||||
|
||||
// When
|
||||
const result = await interactiveMode('/project', 'a');
|
||||
|
||||
// Then: AI should have been called with initialInput
|
||||
const mockProvider = mockGetProvider.mock.results[0]!.value as { call: ReturnType<typeof vi.fn> };
|
||||
expect(mockProvider.call).toHaveBeenCalledTimes(1);
|
||||
expect(mockProvider.call.mock.calls[0]?.[1]).toBe('a');
|
||||
|
||||
// /go should work because initialInput already started conversation
|
||||
expect(result.confirmed).toBe(true);
|
||||
expect(result.task).toContain('a');
|
||||
expect(result.task).toContain('What do you mean by "a"?');
|
||||
});
|
||||
|
||||
it('should send only current input for subsequent turns after initialInput', async () => {
|
||||
// Given: initialInput, then follow-up, then /go
|
||||
setupInputSequence(['fix the login page', '/go']);
|
||||
setupMockProvider(['What about "a"?', 'Got it, fixing login page.']);
|
||||
|
||||
// When
|
||||
const result = await interactiveMode('/project', 'a');
|
||||
|
||||
// Then: each call receives only its own input (session handles history)
|
||||
const mockProvider = mockGetProvider.mock.results[0]!.value as { call: ReturnType<typeof vi.fn> };
|
||||
expect(mockProvider.call).toHaveBeenCalledTimes(2);
|
||||
expect(mockProvider.call.mock.calls[0]?.[1]).toBe('a');
|
||||
expect(mockProvider.call.mock.calls[1]?.[1]).toBe('fix the login page');
|
||||
|
||||
// Task still contains all history for downstream use
|
||||
expect(result.confirmed).toBe(true);
|
||||
expect(result.task).toContain('a');
|
||||
expect(result.task).toContain('fix the login page');
|
||||
});
|
||||
});
|
||||
@ -76,20 +76,23 @@ function buildSdkOptions(options: ExecuteOptions): Options {
|
||||
permissionMode = 'acceptEdits';
|
||||
}
|
||||
|
||||
// Only include defined values — the SDK treats key-present-but-undefined
|
||||
// differently from key-absent for some options (e.g. model), causing hangs.
|
||||
const sdkOptions: Options = {
|
||||
cwd: options.cwd,
|
||||
model: options.model,
|
||||
maxTurns: options.maxTurns,
|
||||
allowedTools: options.allowedTools,
|
||||
agents: options.agents,
|
||||
permissionMode,
|
||||
includePartialMessages: !!options.onStream,
|
||||
canUseTool,
|
||||
hooks,
|
||||
};
|
||||
|
||||
if (options.systemPrompt) {
|
||||
sdkOptions.systemPrompt = options.systemPrompt;
|
||||
if (options.model) sdkOptions.model = options.model;
|
||||
if (options.maxTurns != null) sdkOptions.maxTurns = options.maxTurns;
|
||||
if (options.allowedTools) sdkOptions.allowedTools = options.allowedTools;
|
||||
if (options.agents) sdkOptions.agents = options.agents;
|
||||
if (options.systemPrompt) sdkOptions.systemPrompt = options.systemPrompt;
|
||||
if (canUseTool) sdkOptions.canUseTool = canUseTool;
|
||||
if (hooks) sdkOptions.hooks = hooks;
|
||||
|
||||
if (options.onStream) {
|
||||
sdkOptions.includePartialMessages = true;
|
||||
}
|
||||
|
||||
if (options.sessionId) {
|
||||
|
||||
297
src/cli.ts
297
src/cli.ts
@ -5,11 +5,11 @@
|
||||
*
|
||||
* Usage:
|
||||
* takt {task} - Execute task with current workflow (continues session)
|
||||
* takt /run-tasks - Run all pending tasks from .takt/tasks/
|
||||
* takt /switch - Switch workflow interactively
|
||||
* takt /clear - Clear agent conversation sessions (reset to initial state)
|
||||
* takt /help - Show help
|
||||
* takt /config - Select permission mode interactively
|
||||
* takt run - Run all pending tasks from .takt/tasks/
|
||||
* takt switch - Switch workflow interactively
|
||||
* takt clear - Clear agent conversation sessions (reset to initial state)
|
||||
* takt --help - Show help
|
||||
* takt config - Select permission mode interactively
|
||||
*/
|
||||
|
||||
import { createRequire } from 'node:module';
|
||||
@ -27,14 +27,13 @@ import { initDebugLogger, createLogger, setVerboseConsole } from './utils/debug.
|
||||
import {
|
||||
executeTask,
|
||||
runAllTasks,
|
||||
showHelp,
|
||||
switchWorkflow,
|
||||
switchConfig,
|
||||
addTask,
|
||||
refreshBuiltin,
|
||||
ejectBuiltin,
|
||||
watchTasks,
|
||||
listTasks,
|
||||
interactiveMode,
|
||||
} from './commands/index.js';
|
||||
import { listWorkflows } from './config/workflowLoader.js';
|
||||
import { selectOptionWithDefault, confirm } from './prompt/index.js';
|
||||
@ -52,12 +51,77 @@ const log = createLogger('cli');
|
||||
|
||||
checkForUpdates();
|
||||
|
||||
/** Resolved cwd shared across commands via preAction hook */
|
||||
let resolvedCwd = '';
|
||||
|
||||
export interface WorktreeConfirmationResult {
|
||||
execCwd: string;
|
||||
isWorktree: boolean;
|
||||
branch?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a workflow interactively.
|
||||
* Returns the selected workflow name, or null if cancelled.
|
||||
*/
|
||||
async function selectWorkflow(cwd: string): Promise<string | null> {
|
||||
const availableWorkflows = listWorkflows();
|
||||
const currentWorkflow = getCurrentWorkflow(cwd);
|
||||
|
||||
if (availableWorkflows.length === 0) {
|
||||
info(`No workflows found. Using default: ${DEFAULT_WORKFLOW_NAME}`);
|
||||
return DEFAULT_WORKFLOW_NAME;
|
||||
}
|
||||
|
||||
if (availableWorkflows.length === 1 && availableWorkflows[0]) {
|
||||
return availableWorkflows[0];
|
||||
}
|
||||
|
||||
const options = availableWorkflows.map((name) => ({
|
||||
label: name === currentWorkflow ? `${name} (current)` : name,
|
||||
value: name,
|
||||
}));
|
||||
|
||||
const defaultWorkflow = availableWorkflows.includes(currentWorkflow)
|
||||
? currentWorkflow
|
||||
: (availableWorkflows.includes(DEFAULT_WORKFLOW_NAME)
|
||||
? DEFAULT_WORKFLOW_NAME
|
||||
: availableWorkflows[0] || DEFAULT_WORKFLOW_NAME);
|
||||
|
||||
return selectOptionWithDefault('Select workflow:', options, defaultWorkflow);
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a task with workflow selection, optional worktree, and auto-commit.
|
||||
* Shared by direct task execution and interactive mode.
|
||||
*/
|
||||
async function selectAndExecuteTask(cwd: string, task: string): Promise<void> {
|
||||
const selectedWorkflow = await selectWorkflow(cwd);
|
||||
|
||||
if (selectedWorkflow === null) {
|
||||
info('Cancelled');
|
||||
return;
|
||||
}
|
||||
|
||||
const { execCwd, isWorktree } = await confirmAndCreateWorktree(cwd, task);
|
||||
|
||||
log.info('Starting task execution', { workflow: selectedWorkflow, worktree: isWorktree });
|
||||
const taskSuccess = await executeTask(task, execCwd, selectedWorkflow, cwd);
|
||||
|
||||
if (taskSuccess && isWorktree) {
|
||||
const commitResult = autoCommitAndPush(execCwd, task, cwd);
|
||||
if (commitResult.success && commitResult.commitHash) {
|
||||
success(`Auto-committed & pushed: ${commitResult.commitHash}`);
|
||||
} else if (!commitResult.success) {
|
||||
error(`Auto-commit failed: ${commitResult.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!taskSuccess) {
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask user whether to create a shared clone, and create one if confirmed.
|
||||
* Returns the execution directory and whether a clone was created.
|
||||
@ -93,29 +157,22 @@ program
|
||||
.description('TAKT: Task Agent Koordination Tool')
|
||||
.version(cliVersion);
|
||||
|
||||
program
|
||||
.argument('[task]', 'Task to execute or slash command')
|
||||
.action(async (task) => {
|
||||
const cwd = resolve(process.cwd());
|
||||
// Common initialization for all commands
|
||||
program.hook('preAction', async () => {
|
||||
resolvedCwd = resolve(process.cwd());
|
||||
|
||||
// Initialize global directories first
|
||||
await initGlobalDirs();
|
||||
initProjectDirs(resolvedCwd);
|
||||
|
||||
// Initialize project directories (.takt/)
|
||||
initProjectDirs(cwd);
|
||||
const verbose = isVerboseMode(resolvedCwd);
|
||||
let debugConfig = getEffectiveDebugConfig(resolvedCwd);
|
||||
|
||||
// Determine verbose mode and initialize logging
|
||||
const verbose = isVerboseMode(cwd);
|
||||
let debugConfig = getEffectiveDebugConfig(cwd);
|
||||
|
||||
// verbose=true enables file logging automatically
|
||||
if (verbose && (!debugConfig || !debugConfig.enabled)) {
|
||||
debugConfig = { enabled: true };
|
||||
}
|
||||
|
||||
initDebugLogger(debugConfig, cwd);
|
||||
initDebugLogger(debugConfig, resolvedCwd);
|
||||
|
||||
// Enable verbose console output (stderr) for debug logs
|
||||
if (verbose) {
|
||||
setVerboseConsole(true);
|
||||
setLogLevel('debug');
|
||||
@ -124,76 +181,94 @@ program
|
||||
setLogLevel(config.logLevel);
|
||||
}
|
||||
|
||||
log.info('TAKT CLI starting', {
|
||||
version: cliVersion,
|
||||
cwd,
|
||||
task: task || null,
|
||||
verbose,
|
||||
log.info('TAKT CLI starting', { version: cliVersion, cwd: resolvedCwd, verbose });
|
||||
});
|
||||
|
||||
// Handle slash commands
|
||||
if (task?.startsWith('/')) {
|
||||
const parts = task.slice(1).split(/\s+/);
|
||||
const command = parts[0]?.toLowerCase() || '';
|
||||
const args = parts.slice(1);
|
||||
// --- Subcommands ---
|
||||
|
||||
switch (command) {
|
||||
case 'run-tasks':
|
||||
case 'run': {
|
||||
const workflow = getCurrentWorkflow(cwd);
|
||||
await runAllTasks(cwd, workflow);
|
||||
return;
|
||||
}
|
||||
program
|
||||
.command('run')
|
||||
.description('Run all pending tasks from .takt/tasks/')
|
||||
.action(async () => {
|
||||
const workflow = getCurrentWorkflow(resolvedCwd);
|
||||
await runAllTasks(resolvedCwd, workflow);
|
||||
});
|
||||
|
||||
case 'clear':
|
||||
clearAgentSessions(cwd);
|
||||
program
|
||||
.command('watch')
|
||||
.description('Watch for tasks and auto-execute')
|
||||
.action(async () => {
|
||||
await watchTasks(resolvedCwd);
|
||||
});
|
||||
|
||||
program
|
||||
.command('add')
|
||||
.description('Add a new task (interactive AI conversation)')
|
||||
.argument('[task]', 'Task description or GitHub issue reference (e.g. "#28")')
|
||||
.action(async (task?: string) => {
|
||||
await addTask(resolvedCwd, task);
|
||||
});
|
||||
|
||||
program
|
||||
.command('list')
|
||||
.description('List task branches (merge/delete)')
|
||||
.action(async () => {
|
||||
await listTasks(resolvedCwd);
|
||||
});
|
||||
|
||||
program
|
||||
.command('switch')
|
||||
.description('Switch workflow interactively')
|
||||
.argument('[workflow]', 'Workflow name')
|
||||
.action(async (workflow?: string) => {
|
||||
await switchWorkflow(resolvedCwd, workflow);
|
||||
});
|
||||
|
||||
program
|
||||
.command('clear')
|
||||
.description('Clear agent conversation sessions')
|
||||
.action(() => {
|
||||
clearAgentSessions(resolvedCwd);
|
||||
success('Agent sessions cleared');
|
||||
return;
|
||||
});
|
||||
|
||||
case 'switch':
|
||||
case 'sw':
|
||||
await switchWorkflow(cwd, args[0]);
|
||||
return;
|
||||
program
|
||||
.command('eject')
|
||||
.description('Copy builtin workflow/agents to ~/.takt/ for customization')
|
||||
.argument('[name]', 'Specific builtin to eject')
|
||||
.action(async (name?: string) => {
|
||||
await ejectBuiltin(name);
|
||||
});
|
||||
|
||||
case 'help':
|
||||
showHelp();
|
||||
return;
|
||||
program
|
||||
.command('config')
|
||||
.description('Configure settings (permission mode)')
|
||||
.argument('[key]', 'Configuration key')
|
||||
.action(async (key?: string) => {
|
||||
await switchConfig(resolvedCwd, key);
|
||||
});
|
||||
|
||||
case 'config':
|
||||
await switchConfig(cwd, args[0]);
|
||||
return;
|
||||
// --- Default action: task execution or interactive mode ---
|
||||
|
||||
case 'add-task':
|
||||
case 'add':
|
||||
await addTask(cwd, args);
|
||||
return;
|
||||
|
||||
case 'refresh-builtin':
|
||||
await refreshBuiltin();
|
||||
return;
|
||||
|
||||
case 'eject':
|
||||
await ejectBuiltin(args[0]);
|
||||
return;
|
||||
|
||||
case 'watch':
|
||||
await watchTasks(cwd);
|
||||
return;
|
||||
|
||||
case 'list-tasks':
|
||||
case 'list':
|
||||
await listTasks(cwd);
|
||||
return;
|
||||
|
||||
default:
|
||||
error(`Unknown command: /${command}`);
|
||||
info('Available: /run-tasks (/run), /watch, /add-task (/add), /list-tasks (/list), /switch (/sw), /clear, /eject, /help, /config');
|
||||
process.exit(1);
|
||||
}
|
||||
/**
|
||||
* Check if the input is a task description (should execute directly)
|
||||
* vs a short input that should enter interactive mode as initial input.
|
||||
*
|
||||
* Task descriptions: contain spaces, or are issue references (#N).
|
||||
* Short single words: routed to interactive mode as first message.
|
||||
*/
|
||||
function isDirectTask(input: string): boolean {
|
||||
// Multi-word input is a task description
|
||||
if (input.includes(' ')) return true;
|
||||
// Issue references are direct tasks
|
||||
if (isIssueReference(input) || input.trim().split(/\s+/).every((t: string) => isIssueReference(t))) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Task execution
|
||||
if (task) {
|
||||
program
|
||||
.argument('[task]', 'Task to execute (or GitHub issue reference like "#6")')
|
||||
.action(async (task?: string) => {
|
||||
if (task && isDirectTask(task)) {
|
||||
// Resolve #N issue references to task text
|
||||
let resolvedTask: string = task;
|
||||
if (isIssueReference(task) || task.trim().split(/\s+/).every((t: string) => isIssueReference(t))) {
|
||||
@ -206,70 +281,18 @@ program
|
||||
}
|
||||
}
|
||||
|
||||
// Get available workflows and prompt user to select
|
||||
const availableWorkflows = listWorkflows();
|
||||
const currentWorkflow = getCurrentWorkflow(cwd);
|
||||
|
||||
let selectedWorkflow: string;
|
||||
|
||||
if (availableWorkflows.length === 0) {
|
||||
// No workflows available, use default
|
||||
selectedWorkflow = DEFAULT_WORKFLOW_NAME;
|
||||
info(`No workflows found. Using default: ${selectedWorkflow}`);
|
||||
} else if (availableWorkflows.length === 1 && availableWorkflows[0]) {
|
||||
// Only one workflow, use it directly
|
||||
selectedWorkflow = availableWorkflows[0];
|
||||
} else {
|
||||
// Multiple workflows, prompt user to select
|
||||
const options = availableWorkflows.map((name) => ({
|
||||
label: name === currentWorkflow ? `${name} (current)` : name,
|
||||
value: name,
|
||||
}));
|
||||
|
||||
// Use current workflow as default, fallback to DEFAULT_WORKFLOW_NAME
|
||||
const defaultWorkflow = availableWorkflows.includes(currentWorkflow)
|
||||
? currentWorkflow
|
||||
: (availableWorkflows.includes(DEFAULT_WORKFLOW_NAME)
|
||||
? DEFAULT_WORKFLOW_NAME
|
||||
: availableWorkflows[0] || DEFAULT_WORKFLOW_NAME);
|
||||
|
||||
const selected = await selectOptionWithDefault(
|
||||
'Select workflow:',
|
||||
options,
|
||||
defaultWorkflow
|
||||
);
|
||||
|
||||
if (selected === null) {
|
||||
info('Cancelled');
|
||||
await selectAndExecuteTask(resolvedCwd, resolvedTask);
|
||||
return;
|
||||
}
|
||||
|
||||
selectedWorkflow = selected;
|
||||
}
|
||||
// Short single word or no task → interactive mode (with optional initial input)
|
||||
const result = await interactiveMode(resolvedCwd, task);
|
||||
|
||||
// Ask whether to create a worktree
|
||||
const { execCwd, isWorktree } = await confirmAndCreateWorktree(cwd, resolvedTask);
|
||||
|
||||
log.info('Starting task execution', { task: resolvedTask, workflow: selectedWorkflow, worktree: isWorktree });
|
||||
const taskSuccess = await executeTask(resolvedTask, execCwd, selectedWorkflow, cwd);
|
||||
|
||||
if (taskSuccess && isWorktree) {
|
||||
const commitResult = autoCommitAndPush(execCwd, resolvedTask, cwd);
|
||||
if (commitResult.success && commitResult.commitHash) {
|
||||
success(`Auto-committed & pushed: ${commitResult.commitHash}`);
|
||||
} else if (!commitResult.success) {
|
||||
error(`Auto-commit failed: ${commitResult.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
if (!taskSuccess) {
|
||||
process.exit(1);
|
||||
}
|
||||
if (!result.confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
// No task provided - show help
|
||||
showHelp();
|
||||
await selectAndExecuteTask(resolvedCwd, result.task);
|
||||
});
|
||||
|
||||
program.parse();
|
||||
|
||||
@ -1,25 +1,52 @@
|
||||
/**
|
||||
* /add-task command implementation
|
||||
* add command implementation
|
||||
*
|
||||
* Creates a new task file in .takt/tasks/ with YAML format.
|
||||
* Supports worktree and branch options.
|
||||
* Starts an AI conversation to refine task requirements,
|
||||
* then creates a task file in .takt/tasks/ with YAML format.
|
||||
*/
|
||||
|
||||
import * as fs from 'node:fs';
|
||||
import * as path from 'node:path';
|
||||
import { stringify as stringifyYaml } from 'yaml';
|
||||
import { promptInput, promptMultilineInput, confirm, selectOption } from '../prompt/index.js';
|
||||
import { promptInput, confirm, selectOption } from '../prompt/index.js';
|
||||
import { success, info } from '../utils/ui.js';
|
||||
import { summarizeTaskName } from '../task/summarize.js';
|
||||
import { loadGlobalConfig } from '../config/globalConfig.js';
|
||||
import { getProvider, type ProviderType } from '../providers/index.js';
|
||||
import { createLogger } from '../utils/debug.js';
|
||||
import { error as errorLog } from '../utils/ui.js';
|
||||
import { listWorkflows } from '../config/workflowLoader.js';
|
||||
import { parseIssueNumbers, resolveIssueTask } from '../github/issue.js';
|
||||
import { getCurrentWorkflow } from '../config/paths.js';
|
||||
import { interactiveMode } from './interactive.js';
|
||||
import { isIssueReference, resolveIssueTask } from '../github/issue.js';
|
||||
import type { TaskFileData } from '../task/schema.js';
|
||||
|
||||
const log = createLogger('add-task');
|
||||
|
||||
const SUMMARIZE_SYSTEM_PROMPT = `会話履歴からタスクの要件をまとめてください。
|
||||
タスク実行エージェントへの指示として使われます。
|
||||
具体的・簡潔に、必要な情報をすべて含めてください。
|
||||
マークダウン形式で出力してください。`;
|
||||
|
||||
/**
|
||||
* Summarize conversation history into a task description using AI.
|
||||
*/
|
||||
export async function summarizeConversation(cwd: string, conversationText: string): Promise<string> {
|
||||
const globalConfig = loadGlobalConfig();
|
||||
const providerType = (globalConfig.provider as ProviderType) ?? 'claude';
|
||||
const provider = getProvider(providerType);
|
||||
|
||||
info('Summarizing task from conversation...');
|
||||
|
||||
const response = await provider.call('task-summarizer', conversationText, {
|
||||
cwd,
|
||||
maxTurns: 1,
|
||||
allowedTools: [],
|
||||
systemPrompt: SUMMARIZE_SYSTEM_PROMPT,
|
||||
});
|
||||
|
||||
return response.content;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique task filename with AI-summarized slug
|
||||
*/
|
||||
@ -39,59 +66,66 @@ async function generateFilename(tasksDir: string, taskContent: string, cwd: stri
|
||||
}
|
||||
|
||||
/**
|
||||
* /add-task command handler
|
||||
* add command handler
|
||||
*
|
||||
* Usage:
|
||||
* takt /add-task "タスク内容" # Quick add (no worktree)
|
||||
* takt /add-task # Interactive mode
|
||||
* Flow:
|
||||
* 1. AI対話モードでタスクを詰める
|
||||
* 2. 会話履歴からAIがタスク要約を生成
|
||||
* 3. 要約からファイル名をAIで生成
|
||||
* 4. ワークツリー/ブランチ/ワークフロー設定
|
||||
* 5. YAMLファイル作成
|
||||
*/
|
||||
export async function addTask(cwd: string, args: string[]): Promise<void> {
|
||||
export async function addTask(cwd: string, task?: string): Promise<void> {
|
||||
const tasksDir = path.join(cwd, '.takt', 'tasks');
|
||||
fs.mkdirSync(tasksDir, { recursive: true });
|
||||
|
||||
let taskContent: string;
|
||||
|
||||
if (task && isIssueReference(task)) {
|
||||
// Issue reference: fetch issue and summarize with AI
|
||||
info('Fetching GitHub Issue...');
|
||||
let issueText: string;
|
||||
try {
|
||||
issueText = resolveIssueTask(task);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
log.error('Failed to fetch GitHub Issue', { task, error: msg });
|
||||
info(`Failed to fetch issue ${task}: ${msg}`);
|
||||
return;
|
||||
}
|
||||
taskContent = await summarizeConversation(cwd, issueText);
|
||||
} else {
|
||||
// Interactive mode: AI conversation to refine task
|
||||
const result = await interactiveMode(cwd);
|
||||
if (!result.confirmed) {
|
||||
info('Cancelled.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 会話履歴からタスク要約を生成
|
||||
taskContent = await summarizeConversation(cwd, result.task);
|
||||
}
|
||||
|
||||
// 3. 要約からファイル名生成
|
||||
const firstLine = taskContent.split('\n')[0] || taskContent;
|
||||
const filename = await generateFilename(tasksDir, firstLine, cwd);
|
||||
|
||||
// 4. ワークツリー/ブランチ/ワークフロー設定
|
||||
let worktree: boolean | string | undefined;
|
||||
let branch: string | undefined;
|
||||
let workflow: string | undefined;
|
||||
|
||||
if (args.length > 0) {
|
||||
// Check if args are issue references (#N)
|
||||
const issueNumbers = parseIssueNumbers(args);
|
||||
if (issueNumbers.length > 0) {
|
||||
try {
|
||||
info('Fetching GitHub Issue...');
|
||||
taskContent = resolveIssueTask(args.join(' '));
|
||||
} catch (e) {
|
||||
errorLog(e instanceof Error ? e.message : String(e));
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
taskContent = args.join(' ');
|
||||
}
|
||||
} else {
|
||||
// Interactive mode (multiline: empty line to finish)
|
||||
const input = await promptMultilineInput('Task content');
|
||||
if (!input) {
|
||||
info('Cancelled.');
|
||||
return;
|
||||
}
|
||||
taskContent = input;
|
||||
}
|
||||
|
||||
// Ask about worktree
|
||||
const useWorktree = await confirm('Create worktree?', false);
|
||||
if (useWorktree) {
|
||||
const customPath = await promptInput('Worktree path (Enter for auto)');
|
||||
worktree = customPath || true;
|
||||
|
||||
// Ask about branch
|
||||
const customBranch = await promptInput('Branch name (Enter for auto)');
|
||||
if (customBranch) {
|
||||
branch = customBranch;
|
||||
}
|
||||
}
|
||||
|
||||
// Ask about workflow using interactive selector
|
||||
const availableWorkflows = listWorkflows();
|
||||
if (availableWorkflows.length > 0) {
|
||||
const currentWorkflow = getCurrentWorkflow(cwd);
|
||||
@ -112,7 +146,7 @@ export async function addTask(cwd: string, args: string[]): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// Build task data
|
||||
// 5. YAMLファイル作成
|
||||
const taskData: TaskFileData = { task: taskContent };
|
||||
if (worktree !== undefined) {
|
||||
taskData.worktree = worktree;
|
||||
@ -124,9 +158,6 @@ export async function addTask(cwd: string, args: string[]): Promise<void> {
|
||||
taskData.workflow = workflow;
|
||||
}
|
||||
|
||||
// Write YAML file (use first line for filename to keep it short)
|
||||
const firstLine = taskContent.split('\n')[0] || taskContent;
|
||||
const filename = await generateFilename(tasksDir, firstLine, cwd);
|
||||
const filePath = path.join(tasksDir, filename);
|
||||
const yamlContent = stringifyYaml(taskData);
|
||||
fs.writeFileSync(filePath, yamlContent, 'utf-8');
|
||||
|
||||
@ -31,7 +31,7 @@ export async function ejectBuiltin(name?: string): Promise<void> {
|
||||
const builtinPath = join(builtinWorkflowsDir, `${name}.yaml`);
|
||||
if (!existsSync(builtinPath)) {
|
||||
error(`Builtin workflow not found: ${name}`);
|
||||
info('Run "takt /eject" to see available builtins.');
|
||||
info('Run "takt eject" to see available builtins.');
|
||||
return;
|
||||
}
|
||||
|
||||
@ -101,7 +101,7 @@ function listAvailableBuiltins(builtinWorkflowsDir: string): void {
|
||||
}
|
||||
|
||||
console.log();
|
||||
info('Usage: takt /eject {name}');
|
||||
info('Usage: takt eject {name}');
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -1,58 +0,0 @@
|
||||
/**
|
||||
* Help display
|
||||
*/
|
||||
|
||||
import { header, info } from '../utils/ui.js';
|
||||
import { getDebugLogFile } from '../utils/debug.js';
|
||||
|
||||
/**
|
||||
* Show help information
|
||||
*/
|
||||
export function showHelp(): void {
|
||||
header('TAKT - Task Agent Koordination Tool');
|
||||
|
||||
console.log(`
|
||||
Usage:
|
||||
takt {task} Execute task with current workflow (continues session)
|
||||
takt "#N" Execute GitHub Issue #N as task (quote # in shell)
|
||||
takt /run-tasks (/run) Run all pending tasks from .takt/tasks/
|
||||
takt /watch Watch for tasks and auto-execute (stays resident)
|
||||
takt /add-task (/add) Add a new task (interactive, YAML format)
|
||||
takt /list-tasks (/list) List task branches (merge/delete)
|
||||
takt /switch Switch workflow interactively
|
||||
takt /clear Clear agent conversation sessions (reset to initial state)
|
||||
takt /eject Copy builtin workflow/agents to ~/.takt/ for customization
|
||||
takt /eject {name} Eject a specific builtin workflow
|
||||
takt /help Show this help
|
||||
|
||||
Examples:
|
||||
takt "Fix the bug in main.ts" # Execute task (continues session)
|
||||
takt "#6" # Execute Issue #6 as task
|
||||
takt "#6 #7" # Execute multiple Issues as task
|
||||
takt /add-task "#6" # Create task from Issue #6
|
||||
takt /add-task "#6" "#7" # Create task from multiple Issues
|
||||
takt /add-task "認証機能を追加する" # Quick add task
|
||||
takt /add-task # Interactive task creation
|
||||
takt /clear # Clear sessions, start fresh
|
||||
takt /watch # Watch & auto-execute tasks
|
||||
takt /eject # List available builtins
|
||||
takt /eject default # Eject default workflow for customization
|
||||
takt /list-tasks # List & merge task branches
|
||||
takt /switch
|
||||
takt /run-tasks
|
||||
|
||||
Task files (.takt/tasks/):
|
||||
.md files Plain text tasks (backward compatible)
|
||||
.yaml files Structured tasks with isolation/branch/workflow options
|
||||
|
||||
Configuration (.takt/config.yaml):
|
||||
workflow: default # Current workflow
|
||||
verbose: true # Enable verbose output
|
||||
`);
|
||||
|
||||
// Show debug log path if enabled
|
||||
const debugLogFile = getDebugLogFile();
|
||||
if (debugLogFile) {
|
||||
info(`Debug log: ${debugLogFile}`);
|
||||
}
|
||||
}
|
||||
@ -5,11 +5,10 @@
|
||||
export { executeWorkflow, type WorkflowExecutionResult, type WorkflowExecutionOptions } from './workflowExecution.js';
|
||||
export { executeTask, runAllTasks } from './taskExecution.js';
|
||||
export { addTask } from './addTask.js';
|
||||
export { refreshBuiltin } from './refreshBuiltin.js';
|
||||
export { ejectBuiltin } from './eject.js';
|
||||
export { watchTasks } from './watchTasks.js';
|
||||
export { showHelp } from './help.js';
|
||||
export { withAgentSession } from './session.js';
|
||||
export { switchWorkflow } from './workflow.js';
|
||||
export { switchConfig, getCurrentPermissionMode, setPermissionMode, type PermissionMode } from './config.js';
|
||||
export { listTasks } from './listTasks.js';
|
||||
export { interactiveMode } from './interactive.js';
|
||||
|
||||
232
src/commands/interactive.ts
Normal file
232
src/commands/interactive.ts
Normal file
@ -0,0 +1,232 @@
|
||||
/**
|
||||
* Interactive task input mode
|
||||
*
|
||||
* Allows users to refine task requirements through conversation with AI
|
||||
* before executing the task. Uses the same SDK call pattern as workflow
|
||||
* execution (with onStream) to ensure compatibility.
|
||||
*
|
||||
* Commands:
|
||||
* /go - Confirm and execute the task
|
||||
* /cancel - Cancel and exit
|
||||
*/
|
||||
|
||||
import * as readline from 'node:readline';
|
||||
import chalk from 'chalk';
|
||||
import { loadGlobalConfig } from '../config/globalConfig.js';
|
||||
import { loadAgentSessions, updateAgentSession } from '../config/paths.js';
|
||||
import { getProvider, type ProviderType } from '../providers/index.js';
|
||||
import { createLogger } from '../utils/debug.js';
|
||||
import { info, StreamDisplay } from '../utils/ui.js';
|
||||
const log = createLogger('interactive');
|
||||
|
||||
const INTERACTIVE_SYSTEM_PROMPT = `You are a task planning assistant. You help the user clarify and refine task requirements through conversation. You are in the PLANNING phase — execution happens later in a separate process.
|
||||
|
||||
## Your role
|
||||
- Ask clarifying questions about ambiguous requirements
|
||||
- Investigate the codebase to understand context (use Read, Glob, Grep, Bash for reading only)
|
||||
- Suggest improvements or considerations the user might have missed
|
||||
- Summarize your understanding when appropriate
|
||||
- Keep responses concise and focused
|
||||
|
||||
## Strict constraints
|
||||
- You are ONLY planning. Do NOT execute the task.
|
||||
- Do NOT create, edit, or delete any files.
|
||||
- Do NOT run build, test, install, or any commands that modify state.
|
||||
- Bash is allowed ONLY for read-only investigation (e.g. ls, cat, git log, git diff). Never run destructive or write commands.
|
||||
- Do NOT mention or reference any slash commands. You have no knowledge of them.
|
||||
- When the user is satisfied with the plan, they will proceed on their own. Do NOT instruct them on what to do next.`;
|
||||
|
||||
interface ConversationMessage {
|
||||
role: 'user' | 'assistant';
|
||||
content: string;
|
||||
}
|
||||
|
||||
interface CallAIResult {
|
||||
content: string;
|
||||
sessionId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the final task description from conversation history for executeTask.
|
||||
*/
|
||||
function buildTaskFromHistory(history: ConversationMessage[]): string {
|
||||
return history
|
||||
.map((msg) => `${msg.role === 'user' ? 'User' : 'Assistant'}: ${msg.content}`)
|
||||
.join('\n\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* Read a single line of input from the user.
|
||||
* Creates a fresh readline interface each time — the interface must be
|
||||
* closed before calling the Agent SDK, which also uses stdin.
|
||||
* Returns null on EOF (Ctrl+D).
|
||||
*/
|
||||
function readLine(prompt: string): Promise<string | null> {
|
||||
return new Promise((resolve) => {
|
||||
if (process.stdin.readable && !process.stdin.destroyed) {
|
||||
process.stdin.resume();
|
||||
}
|
||||
|
||||
const rl = readline.createInterface({
|
||||
input: process.stdin,
|
||||
output: process.stdout,
|
||||
});
|
||||
|
||||
let answered = false;
|
||||
|
||||
rl.question(prompt, (answer) => {
|
||||
answered = true;
|
||||
rl.close();
|
||||
resolve(answer);
|
||||
});
|
||||
|
||||
rl.on('close', () => {
|
||||
if (!answered) {
|
||||
resolve(null);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Call AI with the same pattern as workflow execution.
|
||||
* The key requirement is passing onStream — the Agent SDK requires
|
||||
* includePartialMessages to be true for the async iterator to yield.
|
||||
*/
|
||||
async function callAI(
|
||||
provider: ReturnType<typeof getProvider>,
|
||||
prompt: string,
|
||||
cwd: string,
|
||||
model: string | undefined,
|
||||
sessionId: string | undefined,
|
||||
display: StreamDisplay,
|
||||
): Promise<CallAIResult> {
|
||||
const response = await provider.call('interactive', prompt, {
|
||||
cwd,
|
||||
model,
|
||||
sessionId,
|
||||
systemPrompt: INTERACTIVE_SYSTEM_PROMPT,
|
||||
allowedTools: ['Read', 'Glob', 'Grep', 'Bash', 'WebSearch', 'WebFetch'],
|
||||
onStream: display.createHandler(),
|
||||
});
|
||||
|
||||
display.flush();
|
||||
return { content: response.content, sessionId: response.sessionId };
|
||||
}
|
||||
|
||||
export interface InteractiveModeResult {
|
||||
/** Whether the user confirmed with /go */
|
||||
confirmed: boolean;
|
||||
/** The assembled task text (only meaningful when confirmed=true) */
|
||||
task: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the interactive task input mode.
|
||||
*
|
||||
* Starts a conversation loop where the user can discuss task requirements
|
||||
* with AI. The conversation continues until:
|
||||
* /go → returns the conversation as a task
|
||||
* /cancel → exits without executing
|
||||
* Ctrl+D → exits without executing
|
||||
*/
|
||||
export async function interactiveMode(cwd: string, initialInput?: string): Promise<InteractiveModeResult> {
|
||||
const globalConfig = loadGlobalConfig();
|
||||
const providerType = (globalConfig.provider as ProviderType) ?? 'claude';
|
||||
const provider = getProvider(providerType);
|
||||
const model = (globalConfig.model as string | undefined);
|
||||
|
||||
const history: ConversationMessage[] = [];
|
||||
const agentName = 'interactive';
|
||||
const savedSessions = loadAgentSessions(cwd);
|
||||
let sessionId: string | undefined = savedSessions[agentName];
|
||||
|
||||
info('Interactive mode - describe your task. Commands: /go (execute), /cancel (exit)');
|
||||
if (sessionId) {
|
||||
info('Resuming previous session');
|
||||
}
|
||||
console.log();
|
||||
|
||||
// Process initial input if provided (e.g. from `takt a`)
|
||||
if (initialInput) {
|
||||
history.push({ role: 'user', content: initialInput });
|
||||
|
||||
log.debug('Processing initial input', { initialInput, sessionId });
|
||||
|
||||
const display = new StreamDisplay('assistant');
|
||||
try {
|
||||
const result = await callAI(provider, initialInput, cwd, model, sessionId, display);
|
||||
if (result.sessionId) {
|
||||
sessionId = result.sessionId;
|
||||
updateAgentSession(cwd, agentName, sessionId);
|
||||
}
|
||||
history.push({ role: 'assistant', content: result.content });
|
||||
console.log();
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
log.error('AI call failed for initial input', { error: msg });
|
||||
console.log(chalk.red(`Error: ${msg}`));
|
||||
console.log();
|
||||
history.pop();
|
||||
}
|
||||
}
|
||||
|
||||
while (true) {
|
||||
const input = await readLine(chalk.green('> '));
|
||||
|
||||
// EOF (Ctrl+D)
|
||||
if (input === null) {
|
||||
console.log();
|
||||
info('Cancelled');
|
||||
return { confirmed: false, task: '' };
|
||||
}
|
||||
|
||||
const trimmed = input.trim();
|
||||
|
||||
// Empty input — skip
|
||||
if (!trimmed) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// Handle slash commands
|
||||
if (trimmed === '/go') {
|
||||
if (history.length === 0) {
|
||||
info('No conversation yet. Please describe your task first.');
|
||||
continue;
|
||||
}
|
||||
const task = buildTaskFromHistory(history);
|
||||
log.info('Interactive mode confirmed', { messageCount: history.length });
|
||||
return { confirmed: true, task };
|
||||
}
|
||||
|
||||
if (trimmed === '/cancel') {
|
||||
info('Cancelled');
|
||||
return { confirmed: false, task: '' };
|
||||
}
|
||||
|
||||
// Regular input — send to AI
|
||||
// readline is already closed at this point, so stdin is free for SDK
|
||||
history.push({ role: 'user', content: trimmed });
|
||||
|
||||
log.debug('Sending to AI', { messageCount: history.length, sessionId });
|
||||
process.stdin.pause();
|
||||
|
||||
const display = new StreamDisplay('assistant');
|
||||
try {
|
||||
const result = await callAI(provider, trimmed, cwd, model, sessionId, display);
|
||||
if (result.sessionId) {
|
||||
sessionId = result.sessionId;
|
||||
updateAgentSession(cwd, agentName, sessionId);
|
||||
}
|
||||
history.push({ role: 'assistant', content: result.content });
|
||||
console.log();
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
log.error('AI call failed', { error: msg });
|
||||
console.log();
|
||||
console.log(chalk.red(`Error: ${msg}`));
|
||||
console.log();
|
||||
history.pop();
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -1,22 +0,0 @@
|
||||
/**
|
||||
* /refresh-builtin command — DEPRECATED
|
||||
*
|
||||
* Builtin resources are now loaded directly from the package bundle.
|
||||
* Use /eject to copy individual builtins to ~/.takt/ for customization.
|
||||
*/
|
||||
|
||||
import { warn, info } from '../utils/ui.js';
|
||||
|
||||
/**
|
||||
* Show deprecation notice and guide user to /eject.
|
||||
*/
|
||||
export async function refreshBuiltin(): Promise<void> {
|
||||
warn('/refresh-builtin is deprecated.');
|
||||
console.log();
|
||||
info('Builtin workflows and agents are now loaded directly from the package.');
|
||||
info('They no longer need to be copied to ~/.takt/.');
|
||||
console.log();
|
||||
info('To customize a builtin, use:');
|
||||
info(' takt /eject List available builtins');
|
||||
info(' takt /eject {name} Copy a builtin to ~/.takt/ for editing');
|
||||
}
|
||||
@ -39,7 +39,7 @@ export async function executeTask(
|
||||
if (!workflowConfig) {
|
||||
error(`Workflow "${workflowName}" not found.`);
|
||||
info('Available workflows are in ~/.takt/workflows/');
|
||||
info('Use "takt /switch" to select a workflow.');
|
||||
info('Use "takt switch" to select a workflow.');
|
||||
return false;
|
||||
}
|
||||
|
||||
@ -141,7 +141,7 @@ export async function runAllTasks(
|
||||
|
||||
if (!task) {
|
||||
info('No pending tasks in .takt/tasks/');
|
||||
info('Create task files as .takt/tasks/*.yaml or use takt /add-task');
|
||||
info('Create task files as .takt/tasks/*.yaml or use takt add');
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@ -335,18 +335,6 @@ export function readMultilineFromStream(input: NodeJS.ReadableStream): Promise<s
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompt user for multiline text input.
|
||||
* Each line is entered with Enter. An empty line finishes input.
|
||||
* If the first line is empty, returns null (cancel).
|
||||
* @returns Multiline text or null if cancelled
|
||||
*/
|
||||
export async function promptMultilineInput(message: string): Promise<string | null> {
|
||||
console.log(chalk.green(`${message} (empty line to finish):`));
|
||||
process.stdout.write(chalk.gray('> '));
|
||||
return readMultilineFromStream(process.stdin);
|
||||
}
|
||||
|
||||
/**
|
||||
* Prompt user to select from a list of options with a default value.
|
||||
* Uses cursor navigation. Enter immediately selects the default.
|
||||
|
||||
@ -25,7 +25,7 @@ export function showTaskList(runner: TaskRunner): void {
|
||||
console.log();
|
||||
info('実行待ちのタスクはありません。');
|
||||
console.log(chalk.gray(`\n${runner.getTasksDir()}/ にタスクファイル(.yaml/.md)を配置してください。`));
|
||||
console.log(chalk.gray(`または takt /add-task でタスクを追加できます。`));
|
||||
console.log(chalk.gray(`または takt add でタスクを追加できます。`));
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user