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 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
|
||||||
|
|
||||||
|
|||||||
68
README.md
68
README.md
@ -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:
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|
||||||
|
|||||||
@ -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('会話履歴からタスクの要件をまとめてください'),
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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...');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
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';
|
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) {
|
||||||
|
|||||||
297
src/cli.ts
297
src/cli.ts
@ -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,
|
|
||||||
verbose,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Handle slash commands
|
// --- Subcommands ---
|
||||||
if (task?.startsWith('/')) {
|
|
||||||
const parts = task.slice(1).split(/\s+/);
|
|
||||||
const command = parts[0]?.toLowerCase() || '';
|
|
||||||
const args = parts.slice(1);
|
|
||||||
|
|
||||||
switch (command) {
|
program
|
||||||
case 'run-tasks':
|
.command('run')
|
||||||
case 'run': {
|
.description('Run all pending tasks from .takt/tasks/')
|
||||||
const workflow = getCurrentWorkflow(cwd);
|
.action(async () => {
|
||||||
await runAllTasks(cwd, workflow);
|
const workflow = getCurrentWorkflow(resolvedCwd);
|
||||||
return;
|
await runAllTasks(resolvedCwd, workflow);
|
||||||
}
|
});
|
||||||
|
|
||||||
case 'clear':
|
program
|
||||||
clearAgentSessions(cwd);
|
.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');
|
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).
|
||||||
case 'refresh-builtin':
|
* Short single words: routed to interactive mode as first message.
|
||||||
await refreshBuiltin();
|
*/
|
||||||
return;
|
function isDirectTask(input: string): boolean {
|
||||||
|
// Multi-word input is a task description
|
||||||
case 'eject':
|
if (input.includes(' ')) return true;
|
||||||
await ejectBuiltin(args[0]);
|
// Issue references are direct tasks
|
||||||
return;
|
if (isIssueReference(input) || input.trim().split(/\s+/).every((t: string) => isIssueReference(t))) return true;
|
||||||
|
return false;
|
||||||
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
|
program
|
||||||
if (task) {
|
.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
|
// 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();
|
||||||
|
|||||||
@ -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');
|
||||||
|
|||||||
@ -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}');
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -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 { 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
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) {
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user