takt: github-issue-204-takt-tasks (#205)
This commit is contained in:
parent
25f4bf6e2b
commit
8cb3c87801
63
README.md
63
README.md
@ -186,7 +186,7 @@ takt #6 --auto-pr
|
|||||||
|
|
||||||
### Task Management (add / run / watch / list)
|
### Task Management (add / run / watch / list)
|
||||||
|
|
||||||
Batch processing using task files (`.takt/tasks/`). Useful for accumulating multiple tasks and executing them together later.
|
Batch processing using `.takt/tasks.yaml` with task directories under `.takt/tasks/{slug}/`. Useful for accumulating multiple tasks and executing them together later.
|
||||||
|
|
||||||
#### Add Task (`takt add`)
|
#### Add Task (`takt add`)
|
||||||
|
|
||||||
@ -201,14 +201,14 @@ takt add #28
|
|||||||
#### Execute Tasks (`takt run`)
|
#### Execute Tasks (`takt run`)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Execute all pending tasks in .takt/tasks/
|
# Execute all pending tasks in .takt/tasks.yaml
|
||||||
takt run
|
takt run
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Watch Tasks (`takt watch`)
|
#### Watch Tasks (`takt watch`)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# Monitor .takt/tasks/ and auto-execute tasks (resident process)
|
# Monitor .takt/tasks.yaml and auto-execute tasks (resident process)
|
||||||
takt watch
|
takt watch
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -225,6 +225,13 @@ takt list --non-interactive --action delete --branch takt/my-branch --yes
|
|||||||
takt list --non-interactive --format json
|
takt list --non-interactive --format json
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### Task Directory Workflow (Create / Run / Verify)
|
||||||
|
|
||||||
|
1. Run `takt add` and confirm a pending record is created in `.takt/tasks.yaml`.
|
||||||
|
2. Open the generated `.takt/tasks/{slug}/order.md` and add detailed specifications/references as needed.
|
||||||
|
3. Run `takt run` (or `takt watch`) to execute pending tasks from `tasks.yaml`.
|
||||||
|
4. Verify outputs in `.takt/reports/{slug}/` using the same slug as `task_dir`.
|
||||||
|
|
||||||
### Pipeline Mode (for CI/Automation)
|
### Pipeline Mode (for CI/Automation)
|
||||||
|
|
||||||
Specifying `--pipeline` enables non-interactive pipeline mode. Automatically creates branch → runs piece → commits & pushes. Suitable for CI/CD automation.
|
Specifying `--pipeline` enables non-interactive pipeline mode. Automatically creates branch → runs piece → commits & pushes. Suitable for CI/CD automation.
|
||||||
@ -532,8 +539,8 @@ The model string is passed to the Codex SDK. If unspecified, defaults to `codex`
|
|||||||
|
|
||||||
.takt/ # Project-level configuration
|
.takt/ # Project-level configuration
|
||||||
├── config.yaml # Project config (current piece, etc.)
|
├── config.yaml # Project config (current piece, etc.)
|
||||||
├── tasks/ # Pending task files (.yaml, .md)
|
├── tasks/ # Task input directories (.takt/tasks/{slug}/order.md, etc.)
|
||||||
├── completed/ # Completed tasks and reports
|
├── tasks.yaml # Pending tasks metadata (task_dir, piece, worktree, etc.)
|
||||||
├── reports/ # Execution reports (auto-generated)
|
├── reports/ # Execution reports (auto-generated)
|
||||||
│ └── {timestamp}-{slug}/
|
│ └── {timestamp}-{slug}/
|
||||||
└── logs/ # NDJSON format session logs
|
└── logs/ # NDJSON format session logs
|
||||||
@ -625,32 +632,38 @@ Priority: Environment variables > `config.yaml` settings
|
|||||||
|
|
||||||
## Detailed Guides
|
## Detailed Guides
|
||||||
|
|
||||||
### Task File Formats
|
### Task Directory Format
|
||||||
|
|
||||||
TAKT supports batch processing with task files in `.takt/tasks/`. Both `.yaml`/`.yml` and `.md` file formats are supported.
|
TAKT stores task metadata in `.takt/tasks.yaml`, and each task's long specification in `.takt/tasks/{slug}/`.
|
||||||
|
|
||||||
**YAML format** (recommended, supports worktree/branch/piece options):
|
**Recommended layout**:
|
||||||
|
|
||||||
|
```text
|
||||||
|
.takt/
|
||||||
|
tasks/
|
||||||
|
20260201-015714-foptng/
|
||||||
|
order.md
|
||||||
|
schema.sql
|
||||||
|
wireframe.png
|
||||||
|
tasks.yaml
|
||||||
|
reports/
|
||||||
|
20260201-015714-foptng/
|
||||||
|
```
|
||||||
|
|
||||||
|
**tasks.yaml record**:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
# .takt/tasks/add-auth.yaml
|
tasks:
|
||||||
task: "Add authentication feature"
|
- name: add-auth-feature
|
||||||
worktree: true # Execute in isolated shared clone
|
status: pending
|
||||||
branch: "feat/add-auth" # Branch name (auto-generated if omitted)
|
task_dir: .takt/tasks/20260201-015714-foptng
|
||||||
piece: "default" # Piece specification (uses current if omitted)
|
piece: default
|
||||||
|
created_at: "2026-02-01T01:57:14.000Z"
|
||||||
|
started_at: null
|
||||||
|
completed_at: null
|
||||||
```
|
```
|
||||||
|
|
||||||
**Markdown format** (simple, backward compatible):
|
`takt add` creates `.takt/tasks/{slug}/order.md` automatically and saves `task_dir` to `tasks.yaml`.
|
||||||
|
|
||||||
```markdown
|
|
||||||
# .takt/tasks/add-login-feature.md
|
|
||||||
|
|
||||||
Add login feature to the application.
|
|
||||||
|
|
||||||
Requirements:
|
|
||||||
- Username and password fields
|
|
||||||
- Form validation
|
|
||||||
- Error handling on failure
|
|
||||||
```
|
|
||||||
|
|
||||||
#### Isolated Execution with Shared Clone
|
#### Isolated Execution with Shared Clone
|
||||||
|
|
||||||
|
|||||||
@ -1,37 +1,48 @@
|
|||||||
TAKT Task File Format
|
TAKT Task Directory Format
|
||||||
=====================
|
==========================
|
||||||
|
|
||||||
Tasks placed in this directory (.takt/tasks/) will be processed by TAKT.
|
`.takt/tasks/` is the task input directory. Each task uses one subdirectory.
|
||||||
|
|
||||||
## YAML Format (Recommended)
|
## Directory Layout (Recommended)
|
||||||
|
|
||||||
# .takt/tasks/my-task.yaml
|
.takt/
|
||||||
task: "Task description"
|
tasks/
|
||||||
worktree: true # (optional) true | "/path/to/dir"
|
20260201-015714-foptng/
|
||||||
branch: "feat/my-feature" # (optional) branch name
|
order.md
|
||||||
piece: "default" # (optional) piece name
|
schema.sql
|
||||||
|
wireframe.png
|
||||||
|
|
||||||
|
- Directory name should match the report directory slug.
|
||||||
|
- `order.md` is required.
|
||||||
|
- Other files are optional reference materials.
|
||||||
|
|
||||||
|
## tasks.yaml Format
|
||||||
|
|
||||||
|
Store task metadata in `.takt/tasks.yaml`, and point to the task directory with `task_dir`.
|
||||||
|
|
||||||
|
tasks:
|
||||||
|
- name: add-auth-feature
|
||||||
|
status: pending
|
||||||
|
task_dir: .takt/tasks/20260201-015714-foptng
|
||||||
|
piece: default
|
||||||
|
created_at: "2026-02-01T01:57:14.000Z"
|
||||||
|
started_at: null
|
||||||
|
completed_at: null
|
||||||
|
|
||||||
Fields:
|
Fields:
|
||||||
task (required) Task description (string)
|
task_dir (recommended) Path to task directory that contains `order.md`
|
||||||
worktree (optional) true: create shared clone, "/path": clone at path
|
content (legacy) Inline task text (kept for compatibility)
|
||||||
branch (optional) Branch name (auto-generated if omitted: takt/{timestamp}-{slug})
|
content_file (legacy) Path to task text file (kept for compatibility)
|
||||||
piece (optional) Piece name (uses current piece if omitted)
|
|
||||||
|
|
||||||
## Markdown Format (Simple)
|
## Command Behavior
|
||||||
|
|
||||||
# .takt/tasks/my-task.md
|
- `takt add` creates `.takt/tasks/{slug}/order.md` automatically.
|
||||||
|
- `takt run` and `takt watch` read `.takt/tasks.yaml` and resolve `task_dir`.
|
||||||
Entire file content becomes the task description.
|
- Report output is written to `.takt/reports/{slug}/`.
|
||||||
Supports multiline. No structured options available.
|
|
||||||
|
|
||||||
## Supported Extensions
|
|
||||||
|
|
||||||
.yaml, .yml -> YAML format (parsed and validated)
|
|
||||||
.md -> Markdown format (plain text, backward compatible)
|
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
|
|
||||||
takt /add-task Add a task interactively
|
takt add Add a task and create task directory
|
||||||
takt /run-tasks Run all pending tasks
|
takt run Run all pending tasks in tasks.yaml
|
||||||
takt /watch Watch and auto-run tasks
|
takt watch Watch tasks.yaml and run pending tasks
|
||||||
takt /list-tasks List task branches (merge/delete)
|
takt list List task branches (merge/delete)
|
||||||
|
|||||||
@ -186,7 +186,7 @@ takt #6 --auto-pr
|
|||||||
|
|
||||||
### タスク管理(add / run / watch / list)
|
### タスク管理(add / run / watch / list)
|
||||||
|
|
||||||
タスクファイル(`.takt/tasks/`)を使ったバッチ処理。複数のタスクを積んでおいて、後でまとめて実行する使い方に便利です。
|
`.takt/tasks.yaml` と `.takt/tasks/{slug}/` を使ったバッチ処理。複数のタスクを積んでおいて、後でまとめて実行する使い方に便利です。
|
||||||
|
|
||||||
#### タスクを追加(`takt add`)
|
#### タスクを追加(`takt add`)
|
||||||
|
|
||||||
@ -201,14 +201,14 @@ takt add #28
|
|||||||
#### タスクを実行(`takt run`)
|
#### タスクを実行(`takt run`)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# .takt/tasks/ の保留中タスクをすべて実行
|
# .takt/tasks.yaml の保留中タスクをすべて実行
|
||||||
takt run
|
takt run
|
||||||
```
|
```
|
||||||
|
|
||||||
#### タスクを監視(`takt watch`)
|
#### タスクを監視(`takt watch`)
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# .takt/tasks/ を監視してタスクを自動実行(常駐プロセス)
|
# .takt/tasks.yaml を監視してタスクを自動実行(常駐プロセス)
|
||||||
takt watch
|
takt watch
|
||||||
```
|
```
|
||||||
|
|
||||||
@ -225,6 +225,13 @@ takt list --non-interactive --action delete --branch takt/my-branch --yes
|
|||||||
takt list --non-interactive --format json
|
takt list --non-interactive --format json
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### タスクディレクトリ運用(作成・実行・確認)
|
||||||
|
|
||||||
|
1. `takt add` を実行して `.takt/tasks.yaml` に pending レコードが作られることを確認する。
|
||||||
|
2. 生成された `.takt/tasks/{slug}/order.md` を開き、必要なら仕様や参考資料を追記する。
|
||||||
|
3. `takt run`(または `takt watch`)で `tasks.yaml` の pending タスクを実行する。
|
||||||
|
4. `task_dir` と同じスラッグの `.takt/reports/{slug}/` を確認する。
|
||||||
|
|
||||||
### パイプラインモード(CI/自動化向け)
|
### パイプラインモード(CI/自動化向け)
|
||||||
|
|
||||||
`--pipeline` を指定すると非対話のパイプラインモードに入ります。ブランチ作成 → ピース実行 → commit & push を自動で行います。CI/CD での自動化に適しています。
|
`--pipeline` を指定すると非対話のパイプラインモードに入ります。ブランチ作成 → ピース実行 → commit & push を自動で行います。CI/CD での自動化に適しています。
|
||||||
@ -532,8 +539,8 @@ Claude Code はエイリアス(`opus`、`sonnet`、`haiku`、`opusplan`、`def
|
|||||||
|
|
||||||
.takt/ # プロジェクトレベルの設定
|
.takt/ # プロジェクトレベルの設定
|
||||||
├── config.yaml # プロジェクト設定(現在のピース等)
|
├── config.yaml # プロジェクト設定(現在のピース等)
|
||||||
├── tasks/ # 保留中のタスクファイル(.yaml, .md)
|
├── tasks/ # タスク入力ディレクトリ(.takt/tasks/{slug}/order.md など)
|
||||||
├── completed/ # 完了したタスクとレポート
|
├── tasks.yaml # 保留中タスクのメタデータ(task_dir, piece, worktree など)
|
||||||
├── reports/ # 実行レポート(自動生成)
|
├── reports/ # 実行レポート(自動生成)
|
||||||
│ └── {timestamp}-{slug}/
|
│ └── {timestamp}-{slug}/
|
||||||
└── logs/ # NDJSON 形式のセッションログ
|
└── logs/ # NDJSON 形式のセッションログ
|
||||||
@ -625,32 +632,38 @@ anthropic_api_key: sk-ant-... # Claude (Anthropic) を使う場合
|
|||||||
|
|
||||||
## 詳細ガイド
|
## 詳細ガイド
|
||||||
|
|
||||||
### タスクファイルの形式
|
### タスクディレクトリ形式
|
||||||
|
|
||||||
TAKT は `.takt/tasks/` 内のタスクファイルによるバッチ処理をサポートしています。`.yaml`/`.yml` と `.md` の両方のファイル形式に対応しています。
|
TAKT は `.takt/tasks.yaml` にタスクのメタデータを保存し、長文仕様は `.takt/tasks/{slug}/` に分離して管理します。
|
||||||
|
|
||||||
**YAML形式**(推奨、worktree/branch/pieceオプション対応):
|
**推奨構成**:
|
||||||
|
|
||||||
|
```text
|
||||||
|
.takt/
|
||||||
|
tasks/
|
||||||
|
20260201-015714-foptng/
|
||||||
|
order.md
|
||||||
|
schema.sql
|
||||||
|
wireframe.png
|
||||||
|
tasks.yaml
|
||||||
|
reports/
|
||||||
|
20260201-015714-foptng/
|
||||||
|
```
|
||||||
|
|
||||||
|
**tasks.yaml レコード例**:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
# .takt/tasks/add-auth.yaml
|
tasks:
|
||||||
task: "認証機能を追加する"
|
- name: add-auth-feature
|
||||||
worktree: true # 隔離された共有クローンで実行
|
status: pending
|
||||||
branch: "feat/add-auth" # ブランチ名(省略時は自動生成)
|
task_dir: .takt/tasks/20260201-015714-foptng
|
||||||
piece: "default" # ピース指定(省略時は現在のもの)
|
piece: default
|
||||||
|
created_at: "2026-02-01T01:57:14.000Z"
|
||||||
|
started_at: null
|
||||||
|
completed_at: null
|
||||||
```
|
```
|
||||||
|
|
||||||
**Markdown形式**(シンプル、後方互換):
|
`takt add` は `.takt/tasks/{slug}/order.md` を自動生成し、`tasks.yaml` には `task_dir` を保存します。
|
||||||
|
|
||||||
```markdown
|
|
||||||
# .takt/tasks/add-login-feature.md
|
|
||||||
|
|
||||||
アプリケーションにログイン機能を追加する。
|
|
||||||
|
|
||||||
要件:
|
|
||||||
- ユーザー名とパスワードフィールド
|
|
||||||
- フォームバリデーション
|
|
||||||
- 失敗時のエラーハンドリング
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 共有クローンによる隔離実行
|
#### 共有クローンによる隔離実行
|
||||||
|
|
||||||
|
|||||||
@ -26,13 +26,13 @@ E2Eテストを追加・変更した場合は、このドキュメントも更
|
|||||||
|
|
||||||
## シナリオ一覧
|
## シナリオ一覧
|
||||||
- Add task and run(`e2e/specs/add-and-run.e2e.ts`)
|
- Add task and run(`e2e/specs/add-and-run.e2e.ts`)
|
||||||
- 目的: `.takt/tasks/` にタスクYAMLを配置し、`takt run` が実行できることを確認。
|
- 目的: `.takt/tasks.yaml` に pending タスクを配置し、`takt run` が実行できることを確認。
|
||||||
- LLM: 条件付き(`TAKT_E2E_PROVIDER` が `claude` / `codex` の場合に呼び出す)
|
- LLM: 条件付き(`TAKT_E2E_PROVIDER` が `claude` / `codex` の場合に呼び出す)
|
||||||
- 手順(ユーザー行動/コマンド):
|
- 手順(ユーザー行動/コマンド):
|
||||||
- `.takt/tasks/e2e-test-task.yaml` にタスクを作成(`piece` は `e2e/fixtures/pieces/simple.yaml` を指定)。
|
- `.takt/tasks.yaml` にタスクを作成(`piece` は `e2e/fixtures/pieces/simple.yaml` を指定)。
|
||||||
- `takt run` を実行する。
|
- `takt run` を実行する。
|
||||||
- `README.md` に行が追加されることを確認する。
|
- `README.md` に行が追加されることを確認する。
|
||||||
- タスクファイルが `tasks/` から移動されることを確認する。
|
- 実行後にタスクが `tasks.yaml` から消えることを確認する。
|
||||||
- Worktree/Clone isolation(`e2e/specs/worktree.e2e.ts`)
|
- Worktree/Clone isolation(`e2e/specs/worktree.e2e.ts`)
|
||||||
- 目的: `--create-worktree yes` 指定で隔離環境に実行されることを確認。
|
- 目的: `--create-worktree yes` 指定で隔離環境に実行されることを確認。
|
||||||
- LLM: 条件付き(`TAKT_E2E_PROVIDER` が `claude` / `codex` の場合に呼び出す)
|
- LLM: 条件付き(`TAKT_E2E_PROVIDER` が `claude` / `codex` の場合に呼び出す)
|
||||||
@ -83,13 +83,13 @@ E2Eテストを追加・変更した場合は、このドキュメントも更
|
|||||||
- `gh issue create ...` でIssueを作成する。
|
- `gh issue create ...` でIssueを作成する。
|
||||||
- `TAKT_MOCK_SCENARIO=e2e/fixtures/scenarios/add-task.json` を設定する。
|
- `TAKT_MOCK_SCENARIO=e2e/fixtures/scenarios/add-task.json` を設定する。
|
||||||
- `takt add '#<issue>'` を実行し、`Create worktree?` に `n` で回答する。
|
- `takt add '#<issue>'` を実行し、`Create worktree?` に `n` で回答する。
|
||||||
- `.takt/tasks/` にYAMLが生成されることを確認する。
|
- `.takt/tasks.yaml` に `task_dir` が保存され、`.takt/tasks/{slug}/order.md` が生成されることを確認する。
|
||||||
- Watch tasks(`e2e/specs/watch.e2e.ts`)
|
- Watch tasks(`e2e/specs/watch.e2e.ts`)
|
||||||
- 目的: `takt watch` が監視中に追加されたタスクを実行できることを確認。
|
- 目的: `takt watch` が監視中に追加されたタスクを実行できることを確認。
|
||||||
- LLM: 呼び出さない(`--provider mock` 固定)
|
- LLM: 呼び出さない(`--provider mock` 固定)
|
||||||
- 手順(ユーザー行動/コマンド):
|
- 手順(ユーザー行動/コマンド):
|
||||||
- `takt watch --provider mock` を起動する。
|
- `takt watch --provider mock` を起動する。
|
||||||
- `.takt/tasks/` にタスクYAMLを追加する(`piece` に `e2e/fixtures/pieces/mock-single-step.yaml` を指定)。
|
- `.takt/tasks.yaml` に pending タスクを追加する(`piece` に `e2e/fixtures/pieces/mock-single-step.yaml` を指定)。
|
||||||
- 出力に `Task "watch-task" completed` が含まれることを確認する。
|
- 出力に `Task "watch-task" completed` が含まれることを確認する。
|
||||||
- `Ctrl+C` で終了する。
|
- `Ctrl+C` で終了する。
|
||||||
- Run tasks graceful shutdown on SIGINT(`e2e/specs/run-sigint-graceful.e2e.ts`)
|
- Run tasks graceful shutdown on SIGINT(`e2e/specs/run-sigint-graceful.e2e.ts`)
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||||
import { execFileSync } from 'node:child_process';
|
import { execFileSync } from 'node:child_process';
|
||||||
import { readFileSync, writeFileSync } from 'node:fs';
|
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
||||||
import { join, dirname, resolve } from 'node:path';
|
import { join, dirname, resolve } from 'node:path';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
import { parse as parseYaml } from 'yaml';
|
import { parse as parseYaml } from 'yaml';
|
||||||
@ -87,8 +87,12 @@ describe('E2E: Add task from GitHub issue (takt add)', () => {
|
|||||||
|
|
||||||
const tasksFile = join(testRepo.path, '.takt', 'tasks.yaml');
|
const tasksFile = join(testRepo.path, '.takt', 'tasks.yaml');
|
||||||
const content = readFileSync(tasksFile, 'utf-8');
|
const content = readFileSync(tasksFile, 'utf-8');
|
||||||
const parsed = parseYaml(content) as { tasks?: Array<{ issue?: number }> };
|
const parsed = parseYaml(content) as { tasks?: Array<{ issue?: number; task_dir?: string }> };
|
||||||
expect(parsed.tasks?.length).toBe(1);
|
expect(parsed.tasks?.length).toBe(1);
|
||||||
expect(parsed.tasks?.[0]?.issue).toBe(Number(issueNumber));
|
expect(parsed.tasks?.[0]?.issue).toBe(Number(issueNumber));
|
||||||
|
expect(parsed.tasks?.[0]?.task_dir).toBeTypeOf('string');
|
||||||
|
const orderPath = join(testRepo.path, String(parsed.tasks?.[0]?.task_dir), 'order.md');
|
||||||
|
expect(existsSync(orderPath)).toBe(true);
|
||||||
|
expect(readFileSync(orderPath, 'utf-8')).toContain('E2E Add Issue');
|
||||||
}, 240_000);
|
}, 240_000);
|
||||||
});
|
});
|
||||||
|
|||||||
@ -97,6 +97,10 @@ afterEach(() => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('addTask', () => {
|
describe('addTask', () => {
|
||||||
|
function readOrderContent(dir: string, taskDir: unknown): string {
|
||||||
|
return fs.readFileSync(path.join(dir, String(taskDir), 'order.md'), 'utf-8');
|
||||||
|
}
|
||||||
|
|
||||||
it('should create task entry from interactive result', async () => {
|
it('should create task entry from interactive result', async () => {
|
||||||
mockInteractiveMode.mockResolvedValue({ action: 'execute', task: '# 認証機能追加\nJWT認証を実装する' });
|
mockInteractiveMode.mockResolvedValue({ action: 'execute', task: '# 認証機能追加\nJWT認証を実装する' });
|
||||||
|
|
||||||
@ -104,7 +108,9 @@ describe('addTask', () => {
|
|||||||
|
|
||||||
const tasks = loadTasks(testDir).tasks;
|
const tasks = loadTasks(testDir).tasks;
|
||||||
expect(tasks).toHaveLength(1);
|
expect(tasks).toHaveLength(1);
|
||||||
expect(tasks[0]?.content).toContain('JWT認証を実装する');
|
expect(tasks[0]?.content).toBeUndefined();
|
||||||
|
expect(tasks[0]?.task_dir).toBeTypeOf('string');
|
||||||
|
expect(readOrderContent(testDir, tasks[0]?.task_dir)).toContain('JWT認証を実装する');
|
||||||
expect(tasks[0]?.piece).toBe('default');
|
expect(tasks[0]?.piece).toBe('default');
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -128,7 +134,8 @@ describe('addTask', () => {
|
|||||||
|
|
||||||
expect(mockInteractiveMode).not.toHaveBeenCalled();
|
expect(mockInteractiveMode).not.toHaveBeenCalled();
|
||||||
const task = loadTasks(testDir).tasks[0]!;
|
const task = loadTasks(testDir).tasks[0]!;
|
||||||
expect(task.content).toContain('Fix login timeout');
|
expect(task.content).toBeUndefined();
|
||||||
|
expect(readOrderContent(testDir, task.task_dir)).toContain('Fix login timeout');
|
||||||
expect(task.issue).toBe(99);
|
expect(task.issue).toBe(99);
|
||||||
});
|
});
|
||||||
|
|
||||||
@ -153,7 +160,8 @@ describe('addTask', () => {
|
|||||||
const tasks = loadTasks(testDir).tasks;
|
const tasks = loadTasks(testDir).tasks;
|
||||||
expect(tasks).toHaveLength(1);
|
expect(tasks).toHaveLength(1);
|
||||||
expect(tasks[0]?.issue).toBe(55);
|
expect(tasks[0]?.issue).toBe(55);
|
||||||
expect(tasks[0]?.content).toContain('New feature');
|
expect(tasks[0]?.content).toBeUndefined();
|
||||||
|
expect(readOrderContent(testDir, tasks[0]?.task_dir)).toContain('New feature');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should not save task when issue creation fails in create_issue action', async () => {
|
it('should not save task when issue creation fails in create_issue action', async () => {
|
||||||
|
|||||||
@ -198,4 +198,47 @@ describe('PieceEngine: worktree reportDir resolution', () => {
|
|||||||
const expectedPath = join(normalDir, '.takt/reports/test-report-dir');
|
const expectedPath = join(normalDir, '.takt/reports/test-report-dir');
|
||||||
expect(phaseCtx.reportDir).toBe(expectedPath);
|
expect(phaseCtx.reportDir).toBe(expectedPath);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should use explicit reportDirName when provided', async () => {
|
||||||
|
const normalDir = projectCwd;
|
||||||
|
const config = buildSimpleConfig();
|
||||||
|
const engine = new PieceEngine(config, normalDir, 'test task', {
|
||||||
|
projectCwd: normalDir,
|
||||||
|
reportDirName: '20260201-015714-foptng',
|
||||||
|
});
|
||||||
|
|
||||||
|
mockRunAgentSequence([
|
||||||
|
makeResponse({ persona: 'review', content: 'Review done' }),
|
||||||
|
]);
|
||||||
|
mockDetectMatchedRuleSequence([
|
||||||
|
{ index: 0, method: 'tag' as const },
|
||||||
|
]);
|
||||||
|
|
||||||
|
await engine.run();
|
||||||
|
|
||||||
|
const reportPhaseMock = vi.mocked(runReportPhase);
|
||||||
|
expect(reportPhaseMock).toHaveBeenCalled();
|
||||||
|
const phaseCtx = reportPhaseMock.mock.calls[0][2] as { reportDir: string };
|
||||||
|
expect(phaseCtx.reportDir).toBe(join(normalDir, '.takt/reports/20260201-015714-foptng'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject invalid explicit reportDirName', () => {
|
||||||
|
const normalDir = projectCwd;
|
||||||
|
const config = buildSimpleConfig();
|
||||||
|
|
||||||
|
expect(() => new PieceEngine(config, normalDir, 'test task', {
|
||||||
|
projectCwd: normalDir,
|
||||||
|
reportDirName: '..',
|
||||||
|
})).toThrow('Invalid reportDirName: ..');
|
||||||
|
|
||||||
|
expect(() => new PieceEngine(config, normalDir, 'test task', {
|
||||||
|
projectCwd: normalDir,
|
||||||
|
reportDirName: '.',
|
||||||
|
})).toThrow('Invalid reportDirName: .');
|
||||||
|
|
||||||
|
expect(() => new PieceEngine(config, normalDir, 'test task', {
|
||||||
|
projectCwd: normalDir,
|
||||||
|
reportDirName: '',
|
||||||
|
})).toThrow('Invalid reportDirName: ');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -42,10 +42,13 @@ function loadTasks(testDir: string): { tasks: Array<Record<string, unknown>> } {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
vi.useFakeTimers();
|
||||||
|
vi.setSystemTime(new Date('2026-02-10T04:40:00.000Z'));
|
||||||
testDir = fs.mkdtempSync(path.join(tmpdir(), 'takt-test-save-'));
|
testDir = fs.mkdtempSync(path.join(tmpdir(), 'takt-test-save-'));
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
vi.useRealTimers();
|
||||||
if (testDir && fs.existsSync(testDir)) {
|
if (testDir && fs.existsSync(testDir)) {
|
||||||
fs.rmSync(testDir, { recursive: true });
|
fs.rmSync(testDir, { recursive: true });
|
||||||
}
|
}
|
||||||
@ -61,7 +64,11 @@ describe('saveTaskFile', () => {
|
|||||||
|
|
||||||
const tasks = loadTasks(testDir).tasks;
|
const tasks = loadTasks(testDir).tasks;
|
||||||
expect(tasks).toHaveLength(1);
|
expect(tasks).toHaveLength(1);
|
||||||
expect(tasks[0]?.content).toContain('Implement feature X');
|
expect(tasks[0]?.content).toBeUndefined();
|
||||||
|
expect(tasks[0]?.task_dir).toBeTypeOf('string');
|
||||||
|
const taskDir = path.join(testDir, String(tasks[0]?.task_dir));
|
||||||
|
expect(fs.existsSync(path.join(taskDir, 'order.md'))).toBe(true);
|
||||||
|
expect(fs.readFileSync(path.join(taskDir, 'order.md'), 'utf-8')).toContain('Implement feature X');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should include optional fields', async () => {
|
it('should include optional fields', async () => {
|
||||||
@ -79,6 +86,7 @@ describe('saveTaskFile', () => {
|
|||||||
expect(task.worktree).toBe(true);
|
expect(task.worktree).toBe(true);
|
||||||
expect(task.branch).toBe('feat/my-branch');
|
expect(task.branch).toBe('feat/my-branch');
|
||||||
expect(task.auto_pr).toBe(false);
|
expect(task.auto_pr).toBe(false);
|
||||||
|
expect(task.task_dir).toBeTypeOf('string');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should generate unique names on duplicates', async () => {
|
it('should generate unique names on duplicates', async () => {
|
||||||
@ -86,6 +94,13 @@ describe('saveTaskFile', () => {
|
|||||||
const second = await saveTaskFile(testDir, 'Same title');
|
const second = await saveTaskFile(testDir, 'Same title');
|
||||||
|
|
||||||
expect(first.taskName).not.toBe(second.taskName);
|
expect(first.taskName).not.toBe(second.taskName);
|
||||||
|
|
||||||
|
const tasks = loadTasks(testDir).tasks;
|
||||||
|
expect(tasks).toHaveLength(2);
|
||||||
|
expect(tasks[0]?.task_dir).toBe('.takt/tasks/20260210-044000-same-title');
|
||||||
|
expect(tasks[1]?.task_dir).toBe('.takt/tasks/20260210-044000-same-title-2');
|
||||||
|
expect(fs.readFileSync(path.join(testDir, String(tasks[0]?.task_dir), 'order.md'), 'utf-8')).toContain('Same title');
|
||||||
|
expect(fs.readFileSync(path.join(testDir, String(tasks[1]?.task_dir), 'order.md'), 'utf-8')).toContain('Same title');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@ -216,9 +216,39 @@ describe('TaskRecordSchema', () => {
|
|||||||
expect(() => TaskRecordSchema.parse(record)).not.toThrow();
|
expect(() => TaskRecordSchema.parse(record)).not.toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should reject record with neither content nor content_file', () => {
|
it('should accept record with task_dir', () => {
|
||||||
|
const record = { ...makePendingRecord(), content: undefined, task_dir: '.takt/tasks/20260201-000000-task' };
|
||||||
|
expect(() => TaskRecordSchema.parse(record)).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject record with neither content, content_file, nor task_dir', () => {
|
||||||
const record = { ...makePendingRecord(), content: undefined };
|
const record = { ...makePendingRecord(), content: undefined };
|
||||||
expect(() => TaskRecordSchema.parse(record)).toThrow();
|
expect(() => TaskRecordSchema.parse(record)).toThrow();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should reject record with both content and task_dir', () => {
|
||||||
|
const record = { ...makePendingRecord(), task_dir: '.takt/tasks/20260201-000000-task' };
|
||||||
|
expect(() => TaskRecordSchema.parse(record)).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject record with invalid task_dir format', () => {
|
||||||
|
const record = { ...makePendingRecord(), content: undefined, task_dir: '.takt/reports/invalid' };
|
||||||
|
expect(() => TaskRecordSchema.parse(record)).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject record with parent-directory task_dir', () => {
|
||||||
|
const record = { ...makePendingRecord(), content: undefined, task_dir: '.takt/tasks/..' };
|
||||||
|
expect(() => TaskRecordSchema.parse(record)).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject record with empty content', () => {
|
||||||
|
const record = { ...makePendingRecord(), content: '' };
|
||||||
|
expect(() => TaskRecordSchema.parse(record)).toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reject record with empty content_file', () => {
|
||||||
|
const record = { ...makePendingRecord(), content: undefined, content_file: '' };
|
||||||
|
expect(() => TaskRecordSchema.parse(record)).toThrow();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -161,14 +161,49 @@ describe('TaskRunner (tasks.yaml)', () => {
|
|||||||
expect(tasks[0]?.content).toBe('Absolute task content');
|
expect(tasks[0]?.content).toBe('Absolute task content');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should prefer inline content over content_file', () => {
|
it('should build task instruction from task_dir and expose taskDir on TaskInfo', () => {
|
||||||
|
mkdirSync(join(testDir, '.takt', 'tasks', '20260201-000000-demo'), { recursive: true });
|
||||||
|
writeFileSync(
|
||||||
|
join(testDir, '.takt', 'tasks', '20260201-000000-demo', 'order.md'),
|
||||||
|
'Detailed long spec',
|
||||||
|
'utf-8',
|
||||||
|
);
|
||||||
writeTasksFile(testDir, [createPendingRecord({
|
writeTasksFile(testDir, [createPendingRecord({
|
||||||
content: 'Inline content',
|
content: undefined,
|
||||||
content_file: 'missing-content-file.txt',
|
task_dir: '.takt/tasks/20260201-000000-demo',
|
||||||
})]);
|
})]);
|
||||||
|
|
||||||
const tasks = runner.listTasks();
|
const tasks = runner.listTasks();
|
||||||
expect(tasks[0]?.content).toBe('Inline content');
|
expect(tasks[0]?.taskDir).toBe('.takt/tasks/20260201-000000-demo');
|
||||||
|
expect(tasks[0]?.content).toContain('Implement using only the files');
|
||||||
|
expect(tasks[0]?.content).toContain('.takt/tasks/20260201-000000-demo');
|
||||||
|
expect(tasks[0]?.content).toContain('.takt/tasks/20260201-000000-demo/order.md');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw when task_dir order.md is missing', () => {
|
||||||
|
mkdirSync(join(testDir, '.takt', 'tasks', '20260201-000000-missing'), { recursive: true });
|
||||||
|
writeTasksFile(testDir, [createPendingRecord({
|
||||||
|
content: undefined,
|
||||||
|
task_dir: '.takt/tasks/20260201-000000-missing',
|
||||||
|
})]);
|
||||||
|
|
||||||
|
expect(() => runner.listTasks()).toThrow(/Task spec file is missing/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should reset tasks file when both content and content_file are set', () => {
|
||||||
|
writeTasksFile(testDir, [{
|
||||||
|
name: 'task-a',
|
||||||
|
status: 'pending',
|
||||||
|
content: 'Inline content',
|
||||||
|
content_file: 'missing-content-file.txt',
|
||||||
|
created_at: '2026-02-09T00:00:00.000Z',
|
||||||
|
started_at: null,
|
||||||
|
completed_at: null,
|
||||||
|
owner_pid: null,
|
||||||
|
}]);
|
||||||
|
|
||||||
|
expect(runner.listTasks()).toEqual([]);
|
||||||
|
expect(existsSync(join(testDir, '.takt', 'tasks.yaml'))).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should throw when content_file target is missing', () => {
|
it('should throw when content_file target is missing', () => {
|
||||||
|
|||||||
@ -511,4 +511,52 @@ describe('resolveTaskExecution', () => {
|
|||||||
expect(mockSummarizeTaskName).not.toHaveBeenCalled();
|
expect(mockSummarizeTaskName).not.toHaveBeenCalled();
|
||||||
expect(mockCreateSharedClone).not.toHaveBeenCalled();
|
expect(mockCreateSharedClone).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('should return reportDirName from taskDir basename', async () => {
|
||||||
|
const task: TaskInfo = {
|
||||||
|
name: 'task-with-dir',
|
||||||
|
content: 'Task content',
|
||||||
|
taskDir: '.takt/tasks/20260201-015714-foptng',
|
||||||
|
filePath: '/tasks/task.yaml',
|
||||||
|
data: {
|
||||||
|
task: 'Task content',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await resolveTaskExecution(task, '/project', 'default');
|
||||||
|
|
||||||
|
expect(result.reportDirName).toBe('20260201-015714-foptng');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw when taskDir format is invalid', async () => {
|
||||||
|
const task: TaskInfo = {
|
||||||
|
name: 'task-with-invalid-dir',
|
||||||
|
content: 'Task content',
|
||||||
|
taskDir: '.takt/reports/20260201-015714-foptng',
|
||||||
|
filePath: '/tasks/task.yaml',
|
||||||
|
data: {
|
||||||
|
task: 'Task content',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(resolveTaskExecution(task, '/project', 'default')).rejects.toThrow(
|
||||||
|
'Invalid task_dir format: .takt/reports/20260201-015714-foptng',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should throw when taskDir contains parent directory segment', async () => {
|
||||||
|
const task: TaskInfo = {
|
||||||
|
name: 'task-with-parent-dir',
|
||||||
|
content: 'Task content',
|
||||||
|
taskDir: '.takt/tasks/..',
|
||||||
|
filePath: '/tasks/task.yaml',
|
||||||
|
data: {
|
||||||
|
task: 'Task content',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await expect(resolveTaskExecution(task, '/project', 'default')).rejects.toThrow(
|
||||||
|
'Invalid task_dir format: .takt/tasks/..',
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -15,7 +15,7 @@ import { resolveAgentOverrides } from './helpers.js';
|
|||||||
|
|
||||||
program
|
program
|
||||||
.command('run')
|
.command('run')
|
||||||
.description('Run all pending tasks from .takt/tasks/')
|
.description('Run all pending tasks from .takt/tasks.yaml')
|
||||||
.action(async () => {
|
.action(async () => {
|
||||||
const piece = getCurrentPiece(resolvedCwd);
|
const piece = getCurrentPiece(resolvedCwd);
|
||||||
await runAllTasks(resolvedCwd, piece, resolveAgentOverrides(program));
|
await runAllTasks(resolvedCwd, piece, resolveAgentOverrides(program));
|
||||||
|
|||||||
@ -27,7 +27,7 @@ import {
|
|||||||
addUserInput as addUserInputToState,
|
addUserInput as addUserInputToState,
|
||||||
incrementMovementIteration,
|
incrementMovementIteration,
|
||||||
} from './state-manager.js';
|
} from './state-manager.js';
|
||||||
import { generateReportDir, getErrorMessage, createLogger } from '../../../shared/utils/index.js';
|
import { generateReportDir, getErrorMessage, createLogger, isValidReportDirName } from '../../../shared/utils/index.js';
|
||||||
import { OptionsBuilder } from './OptionsBuilder.js';
|
import { OptionsBuilder } from './OptionsBuilder.js';
|
||||||
import { MovementExecutor } from './MovementExecutor.js';
|
import { MovementExecutor } from './MovementExecutor.js';
|
||||||
import { ParallelRunner } from './ParallelRunner.js';
|
import { ParallelRunner } from './ParallelRunner.js';
|
||||||
@ -79,7 +79,11 @@ export class PieceEngine extends EventEmitter {
|
|||||||
this.options = options;
|
this.options = options;
|
||||||
this.loopDetector = new LoopDetector(config.loopDetection);
|
this.loopDetector = new LoopDetector(config.loopDetection);
|
||||||
this.cycleDetector = new CycleDetector(config.loopMonitors ?? []);
|
this.cycleDetector = new CycleDetector(config.loopMonitors ?? []);
|
||||||
this.reportDir = `.takt/reports/${generateReportDir(task)}`;
|
if (options.reportDirName !== undefined && !isValidReportDirName(options.reportDirName)) {
|
||||||
|
throw new Error(`Invalid reportDirName: ${options.reportDirName}`);
|
||||||
|
}
|
||||||
|
const reportDirName = options.reportDirName ?? generateReportDir(task);
|
||||||
|
this.reportDir = `.takt/reports/${reportDirName}`;
|
||||||
this.ensureReportDirExists();
|
this.ensureReportDirExists();
|
||||||
this.validateConfig();
|
this.validateConfig();
|
||||||
this.state = createInitialState(config, options);
|
this.state = createInitialState(config, options);
|
||||||
|
|||||||
@ -190,6 +190,8 @@ export interface PieceEngineOptions {
|
|||||||
startMovement?: string;
|
startMovement?: string;
|
||||||
/** Retry note explaining why task is being retried */
|
/** Retry note explaining why task is being retried */
|
||||||
retryNote?: string;
|
retryNote?: string;
|
||||||
|
/** Override report directory name (without parent path). */
|
||||||
|
reportDirName?: string;
|
||||||
/** Task name prefix for parallel task execution output */
|
/** Task name prefix for parallel task execution output */
|
||||||
taskPrefix?: string;
|
taskPrefix?: string;
|
||||||
/** Color index for task prefix (cycled across tasks) */
|
/** Color index for task prefix (cycled across tasks) */
|
||||||
|
|||||||
@ -6,17 +6,30 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import * as path from 'node:path';
|
import * as path from 'node:path';
|
||||||
|
import * as fs from 'node:fs';
|
||||||
import { promptInput, confirm } from '../../../shared/prompt/index.js';
|
import { promptInput, confirm } from '../../../shared/prompt/index.js';
|
||||||
import { success, info, error } from '../../../shared/ui/index.js';
|
import { success, info, error } from '../../../shared/ui/index.js';
|
||||||
import { TaskRunner, type TaskFileData } from '../../../infra/task/index.js';
|
import { TaskRunner, type TaskFileData } from '../../../infra/task/index.js';
|
||||||
import { getPieceDescription, loadGlobalConfig } from '../../../infra/config/index.js';
|
import { getPieceDescription, loadGlobalConfig } from '../../../infra/config/index.js';
|
||||||
import { determinePiece } from '../execute/selectAndExecute.js';
|
import { determinePiece } from '../execute/selectAndExecute.js';
|
||||||
import { createLogger, getErrorMessage } from '../../../shared/utils/index.js';
|
import { createLogger, getErrorMessage, generateReportDir } from '../../../shared/utils/index.js';
|
||||||
import { isIssueReference, resolveIssueTask, parseIssueNumbers, createIssue } from '../../../infra/github/index.js';
|
import { isIssueReference, resolveIssueTask, parseIssueNumbers, createIssue } from '../../../infra/github/index.js';
|
||||||
import { interactiveMode } from '../../interactive/index.js';
|
import { interactiveMode } from '../../interactive/index.js';
|
||||||
|
|
||||||
const log = createLogger('add-task');
|
const log = createLogger('add-task');
|
||||||
|
|
||||||
|
function resolveUniqueTaskSlug(cwd: string, baseSlug: string): string {
|
||||||
|
let sequence = 1;
|
||||||
|
let slug = baseSlug;
|
||||||
|
let taskDir = path.join(cwd, '.takt', 'tasks', slug);
|
||||||
|
while (fs.existsSync(taskDir)) {
|
||||||
|
sequence += 1;
|
||||||
|
slug = `${baseSlug}-${sequence}`;
|
||||||
|
taskDir = path.join(cwd, '.takt', 'tasks', slug);
|
||||||
|
}
|
||||||
|
return slug;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Save a task entry to .takt/tasks.yaml.
|
* Save a task entry to .takt/tasks.yaml.
|
||||||
*
|
*
|
||||||
@ -29,6 +42,12 @@ export async function saveTaskFile(
|
|||||||
options?: { piece?: string; issue?: number; worktree?: boolean | string; branch?: string; autoPr?: boolean },
|
options?: { piece?: string; issue?: number; worktree?: boolean | string; branch?: string; autoPr?: boolean },
|
||||||
): Promise<{ taskName: string; tasksFile: string }> {
|
): Promise<{ taskName: string; tasksFile: string }> {
|
||||||
const runner = new TaskRunner(cwd);
|
const runner = new TaskRunner(cwd);
|
||||||
|
const taskSlug = resolveUniqueTaskSlug(cwd, generateReportDir(taskContent));
|
||||||
|
const taskDir = path.join(cwd, '.takt', 'tasks', taskSlug);
|
||||||
|
const taskDirRelative = `.takt/tasks/${taskSlug}`;
|
||||||
|
const orderPath = path.join(taskDir, 'order.md');
|
||||||
|
fs.mkdirSync(taskDir, { recursive: true });
|
||||||
|
fs.writeFileSync(orderPath, taskContent, 'utf-8');
|
||||||
const config: Omit<TaskFileData, 'task'> = {
|
const config: Omit<TaskFileData, 'task'> = {
|
||||||
...(options?.worktree !== undefined && { worktree: options.worktree }),
|
...(options?.worktree !== undefined && { worktree: options.worktree }),
|
||||||
...(options?.branch && { branch: options.branch }),
|
...(options?.branch && { branch: options.branch }),
|
||||||
@ -36,7 +55,10 @@ export async function saveTaskFile(
|
|||||||
...(options?.issue !== undefined && { issue: options.issue }),
|
...(options?.issue !== undefined && { issue: options.issue }),
|
||||||
...(options?.autoPr !== undefined && { auto_pr: options.autoPr }),
|
...(options?.autoPr !== undefined && { auto_pr: options.autoPr }),
|
||||||
};
|
};
|
||||||
const created = runner.addTask(taskContent, config);
|
const created = runner.addTask(taskContent, {
|
||||||
|
...config,
|
||||||
|
task_dir: taskDirRelative,
|
||||||
|
});
|
||||||
const tasksFile = path.join(cwd, '.takt', 'tasks.yaml');
|
const tasksFile = path.join(cwd, '.takt', 'tasks.yaml');
|
||||||
log.info('Task created', { taskName: created.name, tasksFile, config });
|
log.info('Task created', { taskName: created.name, tasksFile, config });
|
||||||
return { taskName: created.name, tasksFile };
|
return { taskName: created.name, tasksFile };
|
||||||
|
|||||||
@ -7,7 +7,7 @@
|
|||||||
* (concurrency>1) execution through the same code path.
|
* (concurrency>1) execution through the same code path.
|
||||||
*
|
*
|
||||||
* Polls for newly added tasks at a configurable interval so that tasks
|
* Polls for newly added tasks at a configurable interval so that tasks
|
||||||
* added to .takt/tasks/ during execution are picked up without waiting
|
* added to .takt/tasks.yaml during execution are picked up without waiting
|
||||||
* for an active task to complete.
|
* for an active task to complete.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
|||||||
@ -347,6 +347,7 @@ export async function executePiece(
|
|||||||
callAiJudge,
|
callAiJudge,
|
||||||
startMovement: options.startMovement,
|
startMovement: options.startMovement,
|
||||||
retryNote: options.retryNote,
|
retryNote: options.retryNote,
|
||||||
|
reportDirName: options.reportDirName,
|
||||||
taskPrefix: options.taskPrefix,
|
taskPrefix: options.taskPrefix,
|
||||||
taskColorIndex: options.taskColorIndex,
|
taskColorIndex: options.taskColorIndex,
|
||||||
});
|
});
|
||||||
|
|||||||
@ -5,11 +5,13 @@
|
|||||||
import { loadGlobalConfig } from '../../../infra/config/index.js';
|
import { loadGlobalConfig } from '../../../infra/config/index.js';
|
||||||
import { type TaskInfo, createSharedClone, summarizeTaskName, getCurrentBranch } from '../../../infra/task/index.js';
|
import { type TaskInfo, createSharedClone, summarizeTaskName, getCurrentBranch } from '../../../infra/task/index.js';
|
||||||
import { info } from '../../../shared/ui/index.js';
|
import { info } from '../../../shared/ui/index.js';
|
||||||
|
import { getTaskSlugFromTaskDir } from '../../../shared/utils/taskPaths.js';
|
||||||
|
|
||||||
export interface ResolvedTaskExecution {
|
export interface ResolvedTaskExecution {
|
||||||
execCwd: string;
|
execCwd: string;
|
||||||
execPiece: string;
|
execPiece: string;
|
||||||
isWorktree: boolean;
|
isWorktree: boolean;
|
||||||
|
reportDirName?: string;
|
||||||
branch?: string;
|
branch?: string;
|
||||||
baseBranch?: string;
|
baseBranch?: string;
|
||||||
startMovement?: string;
|
startMovement?: string;
|
||||||
@ -44,8 +46,16 @@ export async function resolveTaskExecution(
|
|||||||
|
|
||||||
let execCwd = defaultCwd;
|
let execCwd = defaultCwd;
|
||||||
let isWorktree = false;
|
let isWorktree = false;
|
||||||
|
let reportDirName: string | undefined;
|
||||||
let branch: string | undefined;
|
let branch: string | undefined;
|
||||||
let baseBranch: string | undefined;
|
let baseBranch: string | undefined;
|
||||||
|
if (task.taskDir) {
|
||||||
|
const taskSlug = getTaskSlugFromTaskDir(task.taskDir);
|
||||||
|
if (!taskSlug) {
|
||||||
|
throw new Error(`Invalid task_dir format: ${task.taskDir}`);
|
||||||
|
}
|
||||||
|
reportDirName = taskSlug;
|
||||||
|
}
|
||||||
|
|
||||||
if (data.worktree) {
|
if (data.worktree) {
|
||||||
throwIfAborted(abortSignal);
|
throwIfAborted(abortSignal);
|
||||||
@ -80,5 +90,16 @@ export async function resolveTaskExecution(
|
|||||||
autoPr = globalConfig.autoPr;
|
autoPr = globalConfig.autoPr;
|
||||||
}
|
}
|
||||||
|
|
||||||
return { execCwd, execPiece, isWorktree, branch, baseBranch, startMovement, retryNote, autoPr, issueNumber: data.issue };
|
return {
|
||||||
|
execCwd,
|
||||||
|
execPiece,
|
||||||
|
isWorktree,
|
||||||
|
...(reportDirName ? { reportDirName } : {}),
|
||||||
|
...(branch ? { branch } : {}),
|
||||||
|
...(baseBranch ? { baseBranch } : {}),
|
||||||
|
...(startMovement ? { startMovement } : {}),
|
||||||
|
...(retryNote ? { retryNote } : {}),
|
||||||
|
...(autoPr !== undefined ? { autoPr } : {}),
|
||||||
|
...(data.issue !== undefined ? { issueNumber: data.issue } : {}),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -49,7 +49,7 @@ function resolveTaskIssue(issueNumber: number | undefined): ReturnType<typeof fe
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function executeTaskWithResult(options: ExecuteTaskOptions): Promise<PieceExecutionResult> {
|
async function executeTaskWithResult(options: ExecuteTaskOptions): Promise<PieceExecutionResult> {
|
||||||
const { task, cwd, pieceIdentifier, projectCwd, agentOverrides, interactiveUserInput, interactiveMetadata, startMovement, retryNote, abortSignal, taskPrefix, taskColorIndex } = options;
|
const { task, cwd, pieceIdentifier, projectCwd, agentOverrides, interactiveUserInput, interactiveMetadata, startMovement, retryNote, reportDirName, abortSignal, taskPrefix, taskColorIndex } = options;
|
||||||
const pieceConfig = loadPieceByIdentifier(pieceIdentifier, projectCwd);
|
const pieceConfig = loadPieceByIdentifier(pieceIdentifier, projectCwd);
|
||||||
|
|
||||||
if (!pieceConfig) {
|
if (!pieceConfig) {
|
||||||
@ -80,6 +80,7 @@ async function executeTaskWithResult(options: ExecuteTaskOptions): Promise<Piece
|
|||||||
interactiveMetadata,
|
interactiveMetadata,
|
||||||
startMovement,
|
startMovement,
|
||||||
retryNote,
|
retryNote,
|
||||||
|
reportDirName,
|
||||||
abortSignal,
|
abortSignal,
|
||||||
taskPrefix,
|
taskPrefix,
|
||||||
taskColorIndex,
|
taskColorIndex,
|
||||||
@ -128,7 +129,7 @@ export async function executeAndCompleteTask(
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const { execCwd, execPiece, isWorktree, branch, baseBranch, startMovement, retryNote, autoPr, issueNumber } = await resolveTaskExecution(task, cwd, pieceName, taskAbortSignal);
|
const { execCwd, execPiece, isWorktree, reportDirName, branch, baseBranch, startMovement, retryNote, autoPr, issueNumber } = await resolveTaskExecution(task, cwd, pieceName, taskAbortSignal);
|
||||||
|
|
||||||
// cwd is always the project root; pass it as projectCwd so reports/sessions go there
|
// cwd is always the project root; pass it as projectCwd so reports/sessions go there
|
||||||
const taskRunResult = await executeTaskWithResult({
|
const taskRunResult = await executeTaskWithResult({
|
||||||
@ -139,6 +140,7 @@ export async function executeAndCompleteTask(
|
|||||||
agentOverrides: options,
|
agentOverrides: options,
|
||||||
startMovement,
|
startMovement,
|
||||||
retryNote,
|
retryNote,
|
||||||
|
reportDirName,
|
||||||
abortSignal: taskAbortSignal,
|
abortSignal: taskAbortSignal,
|
||||||
taskPrefix: parallelOptions?.taskPrefix,
|
taskPrefix: parallelOptions?.taskPrefix,
|
||||||
taskColorIndex: parallelOptions?.taskColorIndex,
|
taskColorIndex: parallelOptions?.taskColorIndex,
|
||||||
@ -227,7 +229,7 @@ export async function executeAndCompleteTask(
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Run all pending tasks from .takt/tasks/
|
* Run all pending tasks from .takt/tasks.yaml
|
||||||
*
|
*
|
||||||
* Uses a worker pool for both sequential (concurrency=1) and parallel
|
* Uses a worker pool for both sequential (concurrency=1) and parallel
|
||||||
* (concurrency>1) execution through the same code path.
|
* (concurrency>1) execution through the same code path.
|
||||||
|
|||||||
@ -42,6 +42,8 @@ export interface PieceExecutionOptions {
|
|||||||
startMovement?: string;
|
startMovement?: string;
|
||||||
/** Retry note explaining why task is being retried */
|
/** Retry note explaining why task is being retried */
|
||||||
retryNote?: string;
|
retryNote?: string;
|
||||||
|
/** Override report directory name (e.g. "20260201-015714-foptng") */
|
||||||
|
reportDirName?: string;
|
||||||
/** External abort signal for parallel execution — when provided, SIGINT handling is delegated to caller */
|
/** External abort signal for parallel execution — when provided, SIGINT handling is delegated to caller */
|
||||||
abortSignal?: AbortSignal;
|
abortSignal?: AbortSignal;
|
||||||
/** Task name prefix for parallel execution output (e.g. "[task-name] output...") */
|
/** Task name prefix for parallel execution output (e.g. "[task-name] output...") */
|
||||||
@ -74,6 +76,8 @@ export interface ExecuteTaskOptions {
|
|||||||
startMovement?: string;
|
startMovement?: string;
|
||||||
/** Retry note explaining why task is being retried */
|
/** Retry note explaining why task is being retried */
|
||||||
retryNote?: string;
|
retryNote?: string;
|
||||||
|
/** Override report directory name (e.g. "20260201-015714-foptng") */
|
||||||
|
reportDirName?: string;
|
||||||
/** External abort signal for parallel execution — when provided, SIGINT handling is delegated to caller */
|
/** External abort signal for parallel execution — when provided, SIGINT handling is delegated to caller */
|
||||||
abortSignal?: AbortSignal;
|
abortSignal?: AbortSignal;
|
||||||
/** Task name prefix for parallel execution output (e.g. "[task-name] output...") */
|
/** Task name prefix for parallel execution output (e.g. "[task-name] output...") */
|
||||||
|
|||||||
@ -2,7 +2,7 @@
|
|||||||
* List tasks command — main entry point.
|
* List tasks command — main entry point.
|
||||||
*
|
*
|
||||||
* Interactive UI for reviewing branch-based task results,
|
* Interactive UI for reviewing branch-based task results,
|
||||||
* pending tasks (.takt/tasks/), and failed tasks (.takt/failed/).
|
* pending tasks (.takt/tasks.yaml), and failed tasks.
|
||||||
* Individual actions (merge, delete, instruct, diff) are in taskActions.ts.
|
* Individual actions (merge, delete, instruct, diff) are in taskActions.ts.
|
||||||
* Task delete actions are in taskDeleteActions.ts.
|
* Task delete actions are in taskDeleteActions.ts.
|
||||||
* Non-interactive mode is in listNonInteractive.ts.
|
* Non-interactive mode is in listNonInteractive.ts.
|
||||||
|
|||||||
@ -7,10 +7,37 @@ function firstLine(content: string): string {
|
|||||||
return content.trim().split('\n')[0]?.slice(0, 80) ?? '';
|
return content.trim().split('\n')[0]?.slice(0, 80) ?? '';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function toDisplayPath(projectDir: string, targetPath: string): string {
|
||||||
|
const relativePath = path.relative(projectDir, targetPath);
|
||||||
|
if (!relativePath || relativePath.startsWith('..')) {
|
||||||
|
return targetPath;
|
||||||
|
}
|
||||||
|
return relativePath;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTaskDirInstruction(projectDir: string, taskDirPath: string, orderFilePath: string): string {
|
||||||
|
const displayTaskDir = toDisplayPath(projectDir, taskDirPath);
|
||||||
|
const displayOrderFile = toDisplayPath(projectDir, orderFilePath);
|
||||||
|
return [
|
||||||
|
`Implement using only the files in \`${displayTaskDir}\`.`,
|
||||||
|
`Primary spec: \`${displayOrderFile}\`.`,
|
||||||
|
'Use report files in Report Directory as primary execution history.',
|
||||||
|
'Do not rely on previous response or conversation summary.',
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
export function resolveTaskContent(projectDir: string, task: TaskRecord): string {
|
export function resolveTaskContent(projectDir: string, task: TaskRecord): string {
|
||||||
if (task.content) {
|
if (task.content) {
|
||||||
return task.content;
|
return task.content;
|
||||||
}
|
}
|
||||||
|
if (task.task_dir) {
|
||||||
|
const taskDirPath = path.join(projectDir, task.task_dir);
|
||||||
|
const orderFilePath = path.join(taskDirPath, 'order.md');
|
||||||
|
if (!fs.existsSync(orderFilePath)) {
|
||||||
|
throw new Error(`Task spec file is missing: ${orderFilePath}`);
|
||||||
|
}
|
||||||
|
return buildTaskDirInstruction(projectDir, taskDirPath, orderFilePath);
|
||||||
|
}
|
||||||
if (!task.content_file) {
|
if (!task.content_file) {
|
||||||
throw new Error(`Task content is missing: ${task.name}`);
|
throw new Error(`Task content is missing: ${task.name}`);
|
||||||
}
|
}
|
||||||
@ -40,6 +67,7 @@ export function toTaskInfo(projectDir: string, tasksFile: string, task: TaskReco
|
|||||||
filePath: tasksFile,
|
filePath: tasksFile,
|
||||||
name: task.name,
|
name: task.name,
|
||||||
content,
|
content,
|
||||||
|
taskDir: task.task_dir,
|
||||||
createdAt: task.created_at,
|
createdAt: task.created_at,
|
||||||
status: task.status,
|
status: task.status,
|
||||||
data: TaskFileSchema.parse({
|
data: TaskFileSchema.parse({
|
||||||
|
|||||||
@ -29,13 +29,17 @@ export class TaskRunner {
|
|||||||
return this.tasksFile;
|
return this.tasksFile;
|
||||||
}
|
}
|
||||||
|
|
||||||
addTask(content: string, options?: Omit<TaskFileData, 'task'>): TaskInfo {
|
addTask(
|
||||||
|
content: string,
|
||||||
|
options?: Omit<TaskFileData, 'task'> & { content_file?: string; task_dir?: string },
|
||||||
|
): TaskInfo {
|
||||||
const state = this.store.update((current) => {
|
const state = this.store.update((current) => {
|
||||||
const name = this.generateTaskName(content, current.tasks.map((task) => task.name));
|
const name = this.generateTaskName(content, current.tasks.map((task) => task.name));
|
||||||
|
const contentValue = options?.task_dir ? undefined : content;
|
||||||
const record: TaskRecord = TaskRecordSchema.parse({
|
const record: TaskRecord = TaskRecordSchema.parse({
|
||||||
name,
|
name,
|
||||||
status: 'pending',
|
status: 'pending',
|
||||||
content,
|
content: contentValue,
|
||||||
created_at: nowIso(),
|
created_at: nowIso(),
|
||||||
started_at: null,
|
started_at: null,
|
||||||
completed_at: null,
|
completed_at: null,
|
||||||
|
|||||||
@ -3,6 +3,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { z } from 'zod/v4';
|
import { z } from 'zod/v4';
|
||||||
|
import { isValidTaskDir } from '../../shared/utils/taskPaths.js';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Per-task execution config schema.
|
* Per-task execution config schema.
|
||||||
@ -40,19 +41,35 @@ export type TaskFailure = z.infer<typeof TaskFailureSchema>;
|
|||||||
export const TaskRecordSchema = TaskExecutionConfigSchema.extend({
|
export const TaskRecordSchema = TaskExecutionConfigSchema.extend({
|
||||||
name: z.string().min(1),
|
name: z.string().min(1),
|
||||||
status: TaskStatusSchema,
|
status: TaskStatusSchema,
|
||||||
content: z.string().optional(),
|
content: z.string().min(1).optional(),
|
||||||
content_file: z.string().optional(),
|
content_file: z.string().min(1).optional(),
|
||||||
|
task_dir: z.string().optional(),
|
||||||
created_at: z.string().min(1),
|
created_at: z.string().min(1),
|
||||||
started_at: z.string().nullable(),
|
started_at: z.string().nullable(),
|
||||||
completed_at: z.string().nullable(),
|
completed_at: z.string().nullable(),
|
||||||
owner_pid: z.number().int().positive().nullable().optional(),
|
owner_pid: z.number().int().positive().nullable().optional(),
|
||||||
failure: TaskFailureSchema.optional(),
|
failure: TaskFailureSchema.optional(),
|
||||||
}).superRefine((value, ctx) => {
|
}).superRefine((value, ctx) => {
|
||||||
if (!value.content && !value.content_file) {
|
const sourceFields = [value.content, value.content_file, value.task_dir].filter((field) => field !== undefined);
|
||||||
|
if (sourceFields.length === 0) {
|
||||||
ctx.addIssue({
|
ctx.addIssue({
|
||||||
code: z.ZodIssueCode.custom,
|
code: z.ZodIssueCode.custom,
|
||||||
path: ['content'],
|
path: ['content'],
|
||||||
message: 'Either content or content_file is required.',
|
message: 'Either content, content_file, or task_dir is required.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (sourceFields.length > 1) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: ['content'],
|
||||||
|
message: 'Exactly one of content, content_file, or task_dir must be set.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (value.task_dir !== undefined && !isValidTaskDir(value.task_dir)) {
|
||||||
|
ctx.addIssue({
|
||||||
|
code: z.ZodIssueCode.custom,
|
||||||
|
path: ['task_dir'],
|
||||||
|
message: 'task_dir must match .takt/tasks/<slug> format.',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -10,6 +10,7 @@ export interface TaskInfo {
|
|||||||
filePath: string;
|
filePath: string;
|
||||||
name: string;
|
name: string;
|
||||||
content: string;
|
content: string;
|
||||||
|
taskDir?: string;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
status: TaskStatus;
|
status: TaskStatus;
|
||||||
data: TaskFileData | null;
|
data: TaskFileData | null;
|
||||||
|
|||||||
@ -8,6 +8,7 @@ export * from './notification.js';
|
|||||||
export * from './reportDir.js';
|
export * from './reportDir.js';
|
||||||
export * from './sleep.js';
|
export * from './sleep.js';
|
||||||
export * from './slug.js';
|
export * from './slug.js';
|
||||||
|
export * from './taskPaths.js';
|
||||||
export * from './text.js';
|
export * from './text.js';
|
||||||
export * from './types.js';
|
export * from './types.js';
|
||||||
export * from './updateNotifier.js';
|
export * from './updateNotifier.js';
|
||||||
|
|||||||
20
src/shared/utils/taskPaths.ts
Normal file
20
src/shared/utils/taskPaths.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
const TASK_SLUG_PATTERN =
|
||||||
|
'[a-z0-9\\u3040-\\u309f\\u30a0-\\u30ff\\u4e00-\\u9faf](?:[a-z0-9\\u3040-\\u309f\\u30a0-\\u30ff\\u4e00-\\u9faf-]*[a-z0-9\\u3040-\\u309f\\u30a0-\\u30ff\\u4e00-\\u9faf])?';
|
||||||
|
const TASK_DIR_PREFIX = '.takt/tasks/';
|
||||||
|
const TASK_DIR_PATTERN = new RegExp(`^\\.takt/tasks/${TASK_SLUG_PATTERN}$`);
|
||||||
|
const REPORT_DIR_NAME_PATTERN = new RegExp(`^${TASK_SLUG_PATTERN}$`);
|
||||||
|
|
||||||
|
export function isValidTaskDir(taskDir: string): boolean {
|
||||||
|
return TASK_DIR_PATTERN.test(taskDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getTaskSlugFromTaskDir(taskDir: string): string | undefined {
|
||||||
|
if (!isValidTaskDir(taskDir)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return taskDir.slice(TASK_DIR_PREFIX.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isValidReportDirName(reportDirName: string): boolean {
|
||||||
|
return REPORT_DIR_NAME_PATTERN.test(reportDirName);
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user