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:
nrslib 2026-01-31 01:13:46 +09:00
parent bcacd7127d
commit 7bac0053ff
18 changed files with 1129 additions and 468 deletions

View File

@ -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 src/__tests__/client.test.ts` | Run single test file |
| `npx vitest run -t "pattern"` | Run tests matching pattern | | `npx vitest run -t "pattern"` | Run tests matching pattern |
## CLI Slash Commands ## CLI Subcommands
| Command | Alias | Description | | Command | Description |
|---------|-------|-------------| |---------|-------------|
| `takt /run-tasks` | `/run` | Execute all pending tasks from `.takt/tasks/` once | | `takt {task}` | Execute task with current workflow |
| `takt /watch` | | Watch `.takt/tasks/` and auto-execute tasks (resident process) | | `takt` | Interactive task input mode |
| `takt /add-task` | `/add` | Add a new task interactively (YAML format, multiline supported) | | `takt run` | Execute all pending tasks from `.takt/tasks/` once |
| `takt /list-tasks` | `/list` | List task branches (try merge, merge & cleanup, or delete) | | `takt watch` | Watch `.takt/tasks/` and auto-execute tasks (resident process) |
| `takt /switch` | `/sw` | Switch workflow interactively | | `takt add` | Add a new task via AI conversation |
| `takt /clear` | | Clear agent conversation sessions (reset state) | | `takt list` | List task branches (try merge, merge & cleanup, or delete) |
| `takt /eject` | | Copy builtin workflow/agents to `~/.takt/` for customization | | `takt switch` | Switch workflow interactively |
| `takt /refresh-builtin` | | Update builtin resources from `resources/` to `~/.takt/` | | `takt clear` | Clear agent conversation sessions (reset state) |
| `takt /help` | | Show help message | | `takt eject` | Copy builtin workflow/agents to `~/.takt/` for customization |
| `takt /config` | | Display current configuration | | `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. 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`. - **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. - **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`. - **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 ## Error Propagation

View File

@ -30,20 +30,20 @@ takt "Add a login feature"
# Run a GitHub issue as a task # Run a GitHub issue as a task
takt "#6" takt "#6"
# Add a task to the queue # Add a task via AI conversation
takt /add-task "Fix the login bug" takt add
# Run all pending tasks # Run all pending tasks
takt /run-tasks takt run
# Watch for tasks and auto-execute # Watch for tasks and auto-execute
takt /watch takt watch
# List task branches (merge or delete) # List task branches (merge or delete)
takt /list-tasks takt list
# Switch workflow # Switch workflow
takt /switch takt switch
``` ```
### What happens when you run a task ### 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 ## Commands
| Command | Alias | Description | | Command | Description |
|---------|-------|-------------| |---------|-------------|
| `takt "task"` | | Execute task with current workflow (session auto-continued) | | `takt "task"` | Execute task with current workflow (session auto-continued) |
| `takt "#N"` | | Execute GitHub issue #N as a task | | `takt "#N"` | Execute GitHub issue #N as a task |
| `takt /run-tasks` | `/run` | Run all pending tasks from `.takt/tasks/` | | `takt` | Interactive task input mode |
| `takt /watch` | | Watch `.takt/tasks/` and auto-execute tasks (stays resident) | | `takt run` | Run all pending tasks from `.takt/tasks/` |
| `takt /add-task` | `/add` | Add a new task interactively (YAML format, multiline supported) | | `takt watch` | Watch `.takt/tasks/` and auto-execute tasks (stays resident) |
| `takt /list-tasks` | `/list` | List task branches (try merge, merge & cleanup, or delete) | | `takt add` | Add a new task via AI conversation |
| `takt /switch` | `/sw` | Switch workflow interactively | | `takt list` | List task branches (try merge, merge & cleanup, or delete) |
| `takt /clear` | | Clear agent conversation sessions | | `takt switch` | Switch workflow interactively |
| `takt /eject` | | Copy builtin workflow/agents to `~/.takt/` for customization | | `takt clear` | Clear agent conversation sessions |
| `takt /refresh-builtin` | | Update builtin agents/workflows to latest version | | `takt eject` | Copy builtin workflow/agents to `~/.takt/` for customization |
| `takt /config` | | Configure permission mode | | `takt config` | Configure permission mode |
| `takt /help` | | Show help | | `takt --help` | Show help |
## Workflows ## 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 ### 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. | | `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. | | `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 ## Built-in Agents
@ -312,7 +312,7 @@ Create your own workflow by adding YAML files to `~/.takt/workflows/`, or use `/
```bash ```bash
# Copy the default workflow to ~/.takt/workflows/ for editing # Copy the default workflow to ~/.takt/workflows/ for editing
takt /eject default takt eject default
``` ```
```yaml ```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. 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 ```bash
# Quick add (no isolation) # Start AI conversation to define and add a task
takt /add-task "Add authentication feature" takt add
# Add a GitHub issue as a task
takt /add-task "#6"
# Interactive mode (prompts for isolation, branch, workflow options)
takt /add-task
``` ```
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 #### Task File Formats
**YAML format** (recommended, supports worktree/branch/workflow options): **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. > **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` #### Running Tasks with `/run-tasks`
```bash ```bash
takt /run-tasks takt run
``` ```
- Tasks are executed in alphabetical order (use prefixes like `001-`, `002-` for ordering) - Tasks are executed in alphabetical order (use prefixes like `001-`, `002-` for ordering)
@ -429,7 +425,7 @@ takt /run-tasks
#### Watching Tasks with `/watch` #### Watching Tasks with `/watch`
```bash ```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: 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` #### Listing Task Branches with `/list-tasks`
```bash ```bash
takt /list-tasks takt list
``` ```
Lists all `takt/`-prefixed branches with file change counts. For each branch you can: Lists all `takt/`-prefixed branches with file change counts. For each branch you can:

View File

@ -26,20 +26,20 @@ takt "ログイン機能を追加して"
# GitHub Issueをタスクとして実行 # GitHub Issueをタスクとして実行
takt "#6" takt "#6"
# タスクをキューに追加 # AI会話でタスクを追加
takt /add-task "ログインのバグを修正" 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 "タスク"` | 現在のワークフローでタスクを実行(セッション自動継続) |
| `takt "#N"` | | GitHub Issue #Nをタスクとして実行 | | `takt "#N"` | GitHub Issue #Nをタスクとして実行 |
| `takt /run-tasks` | `/run` | `.takt/tasks/` の保留中タスクをすべて実行 | | `takt` | 対話式タスク入力モード |
| `takt /watch` | | `.takt/tasks/` を監視してタスクを自動実行(常駐プロセス) | | `takt run` | `.takt/tasks/` の保留中タスクをすべて実行 |
| `takt /add-task` | `/add` | 新しいタスクを対話的に追加YAML形式、複数行対応 | | `takt watch` | `.takt/tasks/` を監視してタスクを自動実行(常駐プロセス |
| `takt /list-tasks` | `/list` | タスクブランチ一覧(マージ・削除) | | `takt add` | AI会話で新しいタスクを追加 |
| `takt /switch` | `/sw` | ワークフローを対話的に切り替え | | `takt list` | タスクブランチ一覧(マージ・削除) |
| `takt /clear` | | エージェントの会話セッションをクリア | | `takt switch` | ワークフローを対話的に切り替え |
| `takt /eject` | | ビルトインのワークフロー/エージェントを`~/.takt/`にコピーしてカスタマイズ | | `takt clear` | エージェントの会話セッションをクリア |
| `takt /refresh-builtin` | | ビルトインのエージェント/ワークフローを最新版に更新 | | `takt eject` | ビルトインのワークフロー/エージェントを`~/.takt/`にコピーしてカスタマイズ |
| `takt /config` | | パーミッションモードを設定 | | `takt config` | パーミッションモードを設定 |
| `takt /help` | | ヘルプを表示 | | `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レビューと修正ループ。 | | `expert-cqrs` | ドメインエキスパートによる逐次レビュー: CQRS+ES、フロントエンド、セキュリティ、QAレビューと修正ループ。 |
| `magi` | エヴァンゲリオンにインスパイアされた審議システム。3つのAIペルソナMELCHIOR、BALTHASAR、CASPERが分析し投票。 | | `magi` | エヴァンゲリオンにインスパイアされた審議システム。3つのAIペルソナMELCHIOR、BALTHASAR、CASPERが分析し投票。 |
`takt /switch` でワークフローを切り替えられます。 `takt switch` でワークフローを切り替えられます。
## ビルトインエージェント ## ビルトインエージェント
@ -296,19 +296,15 @@ trusted_directories:
TAKTは`.takt/tasks/`内のタスクファイルによるバッチ処理をサポートしています。`.yaml`/`.yml``.md`の両方のファイル形式に対応しています。 TAKTは`.takt/tasks/`内のタスクファイルによるバッチ処理をサポートしています。`.yaml`/`.yml``.md`の両方のファイル形式に対応しています。
#### `/add-task` でタスクを追加 #### `takt add` でタスクを追加
```bash ```bash
# クイック追加(隔離なし) # AI会話でタスクの要件を詰めてからタスクを追加
takt /add-task "認証機能を追加" takt add
# GitHub Issueをタスクとして追加
takt /add-task "#6"
# 対話モード(隔離実行、ブランチ、ワークフローオプションを指定可能)
takt /add-task
``` ```
`takt add` はAI会話を開始し、タスクの要件を詰めます。`/go` で確定すると、AIが会話を要約してYAMLタスクファイルを作成します。worktree/branch/workflowの設定も対話的に行えます。
#### タスクファイルの形式 #### タスクファイルの形式
**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`ディレクトリを持つため、この問題が発生しません。 > **Note**: YAMLフィールド名は後方互換のため`worktree`のままです。内部的には`git worktree`ではなく`git clone --shared`を使用しています。git worktreeの`.git`ファイルには`gitdir:`でメインリポジトリへのパスが記載されており、Claude Codeがそれを辿ってメインリポジトリをプロジェクトルートと認識してしまうためです。共有クローンは独立した`.git`ディレクトリを持つため、この問題が発生しません。
クローンは使い捨てです。タスク完了後に自動的にコミット+プッシュし、クローンを削除します。ブランチが唯一の永続的な成果物です。`takt /list-tasks`でブランチの一覧表示・マージ・削除ができます。 クローンは使い捨てです。タスク完了後に自動的にコミット+プッシュし、クローンを削除します。ブランチが唯一の永続的な成果物です。`takt list`でブランチの一覧表示・マージ・削除ができます。
#### `/run-tasks` でタスクを実行 #### `/run-tasks` でタスクを実行
```bash ```bash
takt /run-tasks takt run
``` ```
- タスクはアルファベット順に実行されます(`001-``002-`のようなプレフィックスで順序を制御) - タスクはアルファベット順に実行されます(`001-``002-`のようなプレフィックスで順序を制御)
@ -360,7 +356,7 @@ takt /run-tasks
#### `/watch` でタスクを監視 #### `/watch` でタスクを監視
```bash ```bash
takt /watch takt watch
``` ```
ウォッチモードは`.takt/tasks/`をポーリングし、新しいタスクファイルが現れると自動実行します。`Ctrl+C`で停止する常駐プロセスです。以下のような場合に便利です: ウォッチモードは`.takt/tasks/`をポーリングし、新しいタスクファイルが現れると自動実行します。`Ctrl+C`で停止する常駐プロセスです。以下のような場合に便利です:
@ -371,7 +367,7 @@ takt /watch
#### `/list-tasks` でタスクブランチを一覧表示 #### `/list-tasks` でタスクブランチを一覧表示
```bash ```bash
takt /list-tasks takt list
``` ```
`takt/`プレフィックスのブランチをファイル変更数とともに一覧表示します。各ブランチに対して以下の操作が可能です: `takt/`プレフィックスのブランチをファイル変更数とともに一覧表示します。各ブランチに対して以下の操作が可能です:
@ -398,7 +394,7 @@ TAKTはセッションログをNDJSON`.jsonl`)形式で`.takt/logs/`に書
```bash ```bash
# defaultワークフローを~/.takt/workflows/にコピーして編集 # defaultワークフローを~/.takt/workflows/にコピーして編集
takt /eject default takt eject default
``` ```
```yaml ```yaml

View File

@ -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/`) - Builtin workflows are embedded in the npm package (`dist/resources/`)
- `~/.takt/workflows/` — User workflows (override builtins with the same name) - `~/.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 ## Workflow Schema

View File

@ -8,9 +8,20 @@ import * as path from 'node:path';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
// Mock dependencies before importing the module under test // 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', () => ({ vi.mock('../prompt/index.js', () => ({
promptInput: vi.fn(), promptInput: vi.fn(),
promptMultilineInput: vi.fn(),
confirm: vi.fn(), confirm: vi.fn(),
selectOption: vi.fn(), selectOption: vi.fn(),
})); }));
@ -40,120 +51,157 @@ vi.mock('../config/paths.js', () => ({
getCurrentWorkflow: vi.fn(() => 'default'), 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 { summarizeTaskName } from '../task/summarize.js';
import { listWorkflows } from '../config/workflowLoader.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 mockConfirm = vi.mocked(confirm);
const mockSelectOption = vi.mocked(selectOption); const mockSelectOption = vi.mocked(selectOption);
const mockSummarizeTaskName = vi.mocked(summarizeTaskName); const mockSummarizeTaskName = vi.mocked(summarizeTaskName);
const mockListWorkflows = vi.mocked(listWorkflows); 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; let testDir: string;
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
// Create temporary test directory
testDir = fs.mkdtempSync(path.join(tmpdir(), 'takt-test-')); testDir = fs.mkdtempSync(path.join(tmpdir(), 'takt-test-'));
// Default mock setup
mockListWorkflows.mockReturnValue([]); mockListWorkflows.mockReturnValue([]);
mockConfirm.mockResolvedValue(false); mockConfirm.mockResolvedValue(false);
}); });
afterEach(() => { afterEach(() => {
// Cleanup test directory
if (testDir && fs.existsSync(testDir)) { if (testDir && fs.existsSync(testDir)) {
fs.rmSync(testDir, { recursive: true }); fs.rmSync(testDir, { recursive: true });
} }
}); });
describe('addTask', () => { describe('addTask', () => {
it('should create task file with AI-generated slug for argument mode', async () => { it('should cancel when interactive mode is not confirmed', async () => {
// Given: Task content provided as argument // Given: user cancels interactive mode
mockSummarizeTaskName.mockResolvedValue('add-auth'); mockInteractiveMode.mockResolvedValue({ confirmed: false, task: '' });
mockConfirm.mockResolvedValue(false);
// When // 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 tasksDir = path.join(testDir, '.takt', 'tasks');
const taskFile = path.join(tasksDir, 'add-auth.yaml'); const taskFile = path.join(tasksDir, 'add-auth.yaml');
expect(fs.existsSync(taskFile)).toBe(true); expect(fs.existsSync(taskFile)).toBe(true);
const content = fs.readFileSync(taskFile, 'utf-8'); 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 () => { it('should summarize conversation via provider.call', async () => {
// Given: Japanese task // Given
mockSummarizeTaskName.mockResolvedValue('fix-login-bug'); const { mockProviderCall } = setupFullFlowMocks({
mockConfirm.mockResolvedValue(false); conversationTask: 'User: バグ修正して\n\nAssistant: どのバグですか?',
// 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 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 // When
await addTask(testDir, []); await addTask(testDir);
// Then // Then: provider.call was called with conversation text
expect(mockSummarizeTaskName).toHaveBeenCalledWith('First line task', { cwd: testDir }); 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 () => { it('should use first line of summary for filename generation', async () => {
// Given: AI returns empty slug (which defaults to 'task' in summarizeTaskName) // Given: summary with multiple lines
mockSummarizeTaskName.mockResolvedValue('task'); setupFullFlowMocks({
mockConfirm.mockResolvedValue(false); summaryContent: 'First line summary\nSecond line details',
slug: 'first-line',
});
// When // When
await addTask(testDir, ['test']); await addTask(testDir);
// Then // Then: summarizeTaskName receives only the first line
const taskFile = path.join(testDir, '.takt', 'tasks', 'task.yaml'); expect(mockSummarizeTaskName).toHaveBeenCalledWith('First line summary', { cwd: testDir });
expect(fs.existsSync(taskFile)).toBe(true);
}); });
it('should append counter for duplicate filenames', async () => { it('should append counter for duplicate filenames', async () => {
// Given: First task creates 'my-task.yaml' // Given: first task creates 'my-task.yaml'
mockSummarizeTaskName.mockResolvedValue('my-task'); setupFullFlowMocks({ slug: 'my-task' });
mockConfirm.mockResolvedValue(false); await addTask(testDir);
// When: Create first task // When: create second task with same slug
await addTask(testDir, ['First task']); setupFullFlowMocks({ slug: 'my-task' });
await addTask(testDir);
// And: Create second task with same slug // Then: second file has counter
await addTask(testDir, ['Second task']);
// Then: Second file should have counter
const tasksDir = path.join(testDir, '.takt', 'tasks'); 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.yaml'))).toBe(true);
expect(fs.existsSync(path.join(tasksDir, 'my-task-1.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 () => { it('should include worktree option when confirmed', async () => {
// Given: User confirms worktree creation // Given: user confirms worktree
mockSummarizeTaskName.mockResolvedValue('with-worktree'); setupFullFlowMocks({ slug: 'with-worktree' });
mockConfirm.mockResolvedValue(true); mockConfirm.mockResolvedValue(true);
mockPromptInput.mockResolvedValue('');
// When // When
await addTask(testDir, ['Task with worktree']); await addTask(testDir);
// Then // Then
const taskFile = path.join(testDir, '.takt', 'tasks', 'with-worktree.yaml'); const taskFile = path.join(testDir, '.takt', 'tasks', 'with-worktree.yaml');
@ -161,33 +209,184 @@ describe('addTask', () => {
expect(content).toContain('worktree: true'); expect(content).toContain('worktree: true');
}); });
it('should cancel when interactive mode returns null', async () => { it('should include custom worktree path when provided', async () => {
// Given: User cancels multiline input // Given: user provides custom worktree path
mockPromptMultilineInput.mockResolvedValue(null); setupFullFlowMocks({ slug: 'custom-path' });
mockConfirm.mockResolvedValue(true);
mockPromptInput
.mockResolvedValueOnce('/custom/path')
.mockResolvedValueOnce('');
// When // When
await addTask(testDir, []); await addTask(testDir);
// Then // Then
const tasksDir = path.join(testDir, '.takt', 'tasks'); const taskFile = path.join(testDir, '.takt', 'tasks', 'custom-path.yaml');
const files = fs.existsSync(tasksDir) ? fs.readdirSync(tasksDir) : []; const content = fs.readFileSync(taskFile, 'utf-8');
expect(files.length).toBe(0); expect(content).toContain('worktree: /custom/path');
expect(mockSummarizeTaskName).not.toHaveBeenCalled(); });
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 () => { 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']); mockListWorkflows.mockReturnValue(['default', 'review']);
mockSummarizeTaskName.mockResolvedValue('with-workflow');
mockConfirm.mockResolvedValue(false); mockConfirm.mockResolvedValue(false);
mockSelectOption.mockResolvedValue('review'); mockSelectOption.mockResolvedValue('review');
// When // When
await addTask(testDir, ['Task with workflow']); await addTask(testDir);
// Then // Then
const taskFile = path.join(testDir, '.takt', 'tasks', 'with-workflow.yaml'); const taskFile = path.join(testDir, '.takt', 'tasks', 'with-workflow.yaml');
const content = fs.readFileSync(taskFile, 'utf-8'); const content = fs.readFileSync(taskFile, 'utf-8');
expect(content).toContain('workflow: review'); 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('会話履歴からタスクの要件をまとめてください'),
}),
);
});
}); });

View File

@ -59,13 +59,12 @@ vi.mock('../config/paths.js', () => ({
vi.mock('../commands/index.js', () => ({ vi.mock('../commands/index.js', () => ({
executeTask: vi.fn(), executeTask: vi.fn(),
runAllTasks: vi.fn(), runAllTasks: vi.fn(),
showHelp: vi.fn(),
switchWorkflow: vi.fn(), switchWorkflow: vi.fn(),
switchConfig: vi.fn(), switchConfig: vi.fn(),
addTask: vi.fn(), addTask: vi.fn(),
refreshBuiltin: vi.fn(),
watchTasks: vi.fn(), watchTasks: vi.fn(),
listTasks: vi.fn(), listTasks: vi.fn(),
interactiveMode: vi.fn(() => Promise.resolve({ confirmed: false, task: '' })),
})); }));
vi.mock('../config/workflowLoader.js', () => ({ vi.mock('../config/workflowLoader.js', () => ({
@ -76,6 +75,15 @@ vi.mock('../constants.js', () => ({
DEFAULT_WORKFLOW_NAME: 'default', 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 { confirm } from '../prompt/index.js';
import { createSharedClone } from '../task/clone.js'; import { createSharedClone } from '../task/clone.js';
import { summarizeTaskName } from '../task/summarize.js'; import { summarizeTaskName } from '../task/summarize.js';
@ -193,3 +201,4 @@ describe('confirmAndCreateWorktree', () => {
expect(mockInfo).toHaveBeenCalledWith('Generating branch name...'); expect(mockInfo).toHaveBeenCalledWith('Generating branch name...');
}); });
}); });

View 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');
});
});

View File

@ -76,20 +76,23 @@ function buildSdkOptions(options: ExecuteOptions): Options {
permissionMode = 'acceptEdits'; 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 = { const sdkOptions: Options = {
cwd: options.cwd, cwd: options.cwd,
model: options.model,
maxTurns: options.maxTurns,
allowedTools: options.allowedTools,
agents: options.agents,
permissionMode, permissionMode,
includePartialMessages: !!options.onStream,
canUseTool,
hooks,
}; };
if (options.systemPrompt) { if (options.model) sdkOptions.model = options.model;
sdkOptions.systemPrompt = options.systemPrompt; 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) { if (options.sessionId) {

View File

@ -5,11 +5,11 @@
* *
* Usage: * Usage:
* takt {task} - Execute task with current workflow (continues session) * takt {task} - Execute task with current workflow (continues session)
* takt /run-tasks - Run all pending tasks from .takt/tasks/ * takt run - Run all pending tasks from .takt/tasks/
* takt /switch - Switch workflow interactively * takt switch - Switch workflow interactively
* takt /clear - Clear agent conversation sessions (reset to initial state) * takt clear - Clear agent conversation sessions (reset to initial state)
* takt /help - Show help * takt --help - Show help
* takt /config - Select permission mode interactively * takt config - Select permission mode interactively
*/ */
import { createRequire } from 'node:module'; import { createRequire } from 'node:module';
@ -27,14 +27,13 @@ import { initDebugLogger, createLogger, setVerboseConsole } from './utils/debug.
import { import {
executeTask, executeTask,
runAllTasks, runAllTasks,
showHelp,
switchWorkflow, switchWorkflow,
switchConfig, switchConfig,
addTask, addTask,
refreshBuiltin,
ejectBuiltin, ejectBuiltin,
watchTasks, watchTasks,
listTasks, listTasks,
interactiveMode,
} from './commands/index.js'; } from './commands/index.js';
import { listWorkflows } from './config/workflowLoader.js'; import { listWorkflows } from './config/workflowLoader.js';
import { selectOptionWithDefault, confirm } from './prompt/index.js'; import { selectOptionWithDefault, confirm } from './prompt/index.js';
@ -52,12 +51,77 @@ const log = createLogger('cli');
checkForUpdates(); checkForUpdates();
/** Resolved cwd shared across commands via preAction hook */
let resolvedCwd = '';
export interface WorktreeConfirmationResult { export interface WorktreeConfirmationResult {
execCwd: string; execCwd: string;
isWorktree: boolean; isWorktree: boolean;
branch?: string; 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. * Ask user whether to create a shared clone, and create one if confirmed.
* Returns the execution directory and whether a clone was created. * Returns the execution directory and whether a clone was created.
@ -93,29 +157,22 @@ program
.description('TAKT: Task Agent Koordination Tool') .description('TAKT: Task Agent Koordination Tool')
.version(cliVersion); .version(cliVersion);
program // Common initialization for all commands
.argument('[task]', 'Task to execute or slash command') program.hook('preAction', async () => {
.action(async (task) => { resolvedCwd = resolve(process.cwd());
const cwd = resolve(process.cwd());
// Initialize global directories first
await initGlobalDirs(); await initGlobalDirs();
initProjectDirs(resolvedCwd);
// Initialize project directories (.takt/) const verbose = isVerboseMode(resolvedCwd);
initProjectDirs(cwd); 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)) { if (verbose && (!debugConfig || !debugConfig.enabled)) {
debugConfig = { enabled: true }; debugConfig = { enabled: true };
} }
initDebugLogger(debugConfig, cwd); initDebugLogger(debugConfig, resolvedCwd);
// Enable verbose console output (stderr) for debug logs
if (verbose) { if (verbose) {
setVerboseConsole(true); setVerboseConsole(true);
setLogLevel('debug'); setLogLevel('debug');
@ -124,76 +181,94 @@ program
setLogLevel(config.logLevel); setLogLevel(config.logLevel);
} }
log.info('TAKT CLI starting', { log.info('TAKT CLI starting', { version: cliVersion, cwd: resolvedCwd, verbose });
version: cliVersion, });
cwd,
task: task || null, // --- Subcommands ---
verbose,
program
.command('run')
.description('Run all pending tasks from .takt/tasks/')
.action(async () => {
const workflow = getCurrentWorkflow(resolvedCwd);
await runAllTasks(resolvedCwd, workflow);
}); });
// Handle slash commands program
if (task?.startsWith('/')) { .command('watch')
const parts = task.slice(1).split(/\s+/); .description('Watch for tasks and auto-execute')
const command = parts[0]?.toLowerCase() || ''; .action(async () => {
const args = parts.slice(1); await watchTasks(resolvedCwd);
});
switch (command) { program
case 'run-tasks': .command('add')
case 'run': { .description('Add a new task (interactive AI conversation)')
const workflow = getCurrentWorkflow(cwd); .argument('[task]', 'Task description or GitHub issue reference (e.g. "#28")')
await runAllTasks(cwd, workflow); .action(async (task?: string) => {
return; await addTask(resolvedCwd, task);
} });
case 'clear': program
clearAgentSessions(cwd); .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'); success('Agent sessions cleared');
return; });
case 'switch': program
case 'sw': .command('eject')
await switchWorkflow(cwd, args[0]); .description('Copy builtin workflow/agents to ~/.takt/ for customization')
return; .argument('[name]', 'Specific builtin to eject')
.action(async (name?: string) => {
await ejectBuiltin(name);
});
case 'help': program
showHelp(); .command('config')
return; .description('Configure settings (permission mode)')
.argument('[key]', 'Configuration key')
.action(async (key?: string) => {
await switchConfig(resolvedCwd, key);
});
case 'config': // --- Default action: task execution or interactive mode ---
await switchConfig(cwd, args[0]);
return;
case 'add-task': /**
case 'add': * Check if the input is a task description (should execute directly)
await addTask(cwd, args); * vs a short input that should enter interactive mode as initial input.
return; *
* 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;
}
case 'refresh-builtin': program
await refreshBuiltin(); .argument('[task]', 'Task to execute (or GitHub issue reference like "#6")')
return; .action(async (task?: string) => {
if (task && isDirectTask(task)) {
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);
}
}
// Task execution
if (task) {
// Resolve #N issue references to task text // Resolve #N issue references to task text
let resolvedTask: string = task; let resolvedTask: string = task;
if (isIssueReference(task) || task.trim().split(/\s+/).every((t: string) => isIssueReference(t))) { 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 await selectAndExecuteTask(resolvedCwd, resolvedTask);
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');
return; 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 if (!result.confirmed) {
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);
}
return; return;
} }
// No task provided - show help await selectAndExecuteTask(resolvedCwd, result.task);
showHelp();
}); });
program.parse(); program.parse();

View File

@ -1,25 +1,52 @@
/** /**
* /add-task command implementation * add command implementation
* *
* Creates a new task file in .takt/tasks/ with YAML format. * Starts an AI conversation to refine task requirements,
* Supports worktree and branch options. * then creates a task file in .takt/tasks/ with YAML format.
*/ */
import * as fs from 'node:fs'; import * as fs from 'node:fs';
import * as path from 'node:path'; import * as path from 'node:path';
import { stringify as stringifyYaml } from 'yaml'; 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 { success, info } from '../utils/ui.js';
import { summarizeTaskName } from '../task/summarize.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 { createLogger } from '../utils/debug.js';
import { error as errorLog } from '../utils/ui.js';
import { listWorkflows } from '../config/workflowLoader.js'; import { listWorkflows } from '../config/workflowLoader.js';
import { parseIssueNumbers, resolveIssueTask } from '../github/issue.js';
import { getCurrentWorkflow } from '../config/paths.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'; import type { TaskFileData } from '../task/schema.js';
const log = createLogger('add-task'); 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 * 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: * Flow:
* takt /add-task "タスク内容" # Quick add (no worktree) * 1. AI対話モードでタスクを詰める
* takt /add-task # Interactive mode * 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'); const tasksDir = path.join(cwd, '.takt', 'tasks');
fs.mkdirSync(tasksDir, { recursive: true }); fs.mkdirSync(tasksDir, { recursive: true });
let taskContent: string; 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 worktree: boolean | string | undefined;
let branch: string | undefined; let branch: string | undefined;
let workflow: 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); const useWorktree = await confirm('Create worktree?', false);
if (useWorktree) { if (useWorktree) {
const customPath = await promptInput('Worktree path (Enter for auto)'); const customPath = await promptInput('Worktree path (Enter for auto)');
worktree = customPath || true; worktree = customPath || true;
// Ask about branch
const customBranch = await promptInput('Branch name (Enter for auto)'); const customBranch = await promptInput('Branch name (Enter for auto)');
if (customBranch) { if (customBranch) {
branch = customBranch; branch = customBranch;
} }
} }
// Ask about workflow using interactive selector
const availableWorkflows = listWorkflows(); const availableWorkflows = listWorkflows();
if (availableWorkflows.length > 0) { if (availableWorkflows.length > 0) {
const currentWorkflow = getCurrentWorkflow(cwd); 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 }; const taskData: TaskFileData = { task: taskContent };
if (worktree !== undefined) { if (worktree !== undefined) {
taskData.worktree = worktree; taskData.worktree = worktree;
@ -124,9 +158,6 @@ export async function addTask(cwd: string, args: string[]): Promise<void> {
taskData.workflow = workflow; 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 filePath = path.join(tasksDir, filename);
const yamlContent = stringifyYaml(taskData); const yamlContent = stringifyYaml(taskData);
fs.writeFileSync(filePath, yamlContent, 'utf-8'); fs.writeFileSync(filePath, yamlContent, 'utf-8');

View File

@ -31,7 +31,7 @@ export async function ejectBuiltin(name?: string): Promise<void> {
const builtinPath = join(builtinWorkflowsDir, `${name}.yaml`); const builtinPath = join(builtinWorkflowsDir, `${name}.yaml`);
if (!existsSync(builtinPath)) { if (!existsSync(builtinPath)) {
error(`Builtin workflow not found: ${name}`); error(`Builtin workflow not found: ${name}`);
info('Run "takt /eject" to see available builtins.'); info('Run "takt eject" to see available builtins.');
return; return;
} }
@ -101,7 +101,7 @@ function listAvailableBuiltins(builtinWorkflowsDir: string): void {
} }
console.log(); console.log();
info('Usage: takt /eject {name}'); info('Usage: takt eject {name}');
} }
/** /**

View File

@ -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}`);
}
}

View File

@ -5,11 +5,10 @@
export { executeWorkflow, type WorkflowExecutionResult, type WorkflowExecutionOptions } from './workflowExecution.js'; export { executeWorkflow, type WorkflowExecutionResult, type WorkflowExecutionOptions } from './workflowExecution.js';
export { executeTask, runAllTasks } from './taskExecution.js'; export { executeTask, runAllTasks } from './taskExecution.js';
export { addTask } from './addTask.js'; export { addTask } from './addTask.js';
export { refreshBuiltin } from './refreshBuiltin.js';
export { ejectBuiltin } from './eject.js'; export { ejectBuiltin } from './eject.js';
export { watchTasks } from './watchTasks.js'; export { watchTasks } from './watchTasks.js';
export { showHelp } from './help.js';
export { withAgentSession } from './session.js'; export { withAgentSession } from './session.js';
export { switchWorkflow } from './workflow.js'; export { switchWorkflow } from './workflow.js';
export { switchConfig, getCurrentPermissionMode, setPermissionMode, type PermissionMode } from './config.js'; export { switchConfig, getCurrentPermissionMode, setPermissionMode, type PermissionMode } from './config.js';
export { listTasks } from './listTasks.js'; export { listTasks } from './listTasks.js';
export { interactiveMode } from './interactive.js';

232
src/commands/interactive.ts Normal file
View 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();
}
}
}

View File

@ -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');
}

View File

@ -39,7 +39,7 @@ export async function executeTask(
if (!workflowConfig) { if (!workflowConfig) {
error(`Workflow "${workflowName}" not found.`); error(`Workflow "${workflowName}" not found.`);
info('Available workflows are in ~/.takt/workflows/'); 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; return false;
} }
@ -141,7 +141,7 @@ export async function runAllTasks(
if (!task) { if (!task) {
info('No pending tasks in .takt/tasks/'); 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; return;
} }

View File

@ -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. * Prompt user to select from a list of options with a default value.
* Uses cursor navigation. Enter immediately selects the default. * Uses cursor navigation. Enter immediately selects the default.

View File

@ -25,7 +25,7 @@ export function showTaskList(runner: TaskRunner): void {
console.log(); console.log();
info('実行待ちのタスクはありません。'); info('実行待ちのタスクはありません。');
console.log(chalk.gray(`\n${runner.getTasksDir()}/ にタスクファイル(.yaml/.mdを配置してください。`)); console.log(chalk.gray(`\n${runner.getTasksDir()}/ にタスクファイル(.yaml/.mdを配置してください。`));
console.log(chalk.gray(`または takt /add-task でタスクを追加できます。`)); console.log(chalk.gray(`または takt add でタスクを追加できます。`));
return; return;
} }