From 8cb3c87801fc81ad8127255b6a5c606ed96b112f Mon Sep 17 00:00:00 2001 From: nrs <38722970+nrslib@users.noreply.github.com> Date: Tue, 10 Feb 2026 14:26:37 +0900 Subject: [PATCH] takt: github-issue-204-takt-tasks (#205) --- README.md | 63 +++++++++++------- builtins/project/tasks/TASK-FORMAT | 65 +++++++++++-------- docs/README.ja.md | 63 +++++++++++------- docs/testing/e2e.md | 10 +-- e2e/specs/add.e2e.ts | 8 ++- src/__tests__/addTask.test.ts | 14 +++- src/__tests__/engine-worktree-report.test.ts | 43 ++++++++++++ src/__tests__/saveTaskFile.test.ts | 17 ++++- src/__tests__/task-schema.test.ts | 32 ++++++++- src/__tests__/task.test.ts | 43 ++++++++++-- src/__tests__/taskExecution.test.ts | 48 ++++++++++++++ src/app/cli/commands.ts | 2 +- src/core/piece/engine/PieceEngine.ts | 8 ++- src/core/piece/types.ts | 2 + src/features/tasks/add/index.ts | 26 +++++++- .../tasks/execute/parallelExecution.ts | 2 +- src/features/tasks/execute/pieceExecution.ts | 1 + src/features/tasks/execute/resolveTask.ts | 23 ++++++- src/features/tasks/execute/taskExecution.ts | 8 ++- src/features/tasks/execute/types.ts | 4 ++ src/features/tasks/list/index.ts | 2 +- src/infra/task/mapper.ts | 28 ++++++++ src/infra/task/runner.ts | 8 ++- src/infra/task/schema.ts | 25 +++++-- src/infra/task/types.ts | 1 + src/shared/utils/index.ts | 1 + src/shared/utils/taskPaths.ts | 20 ++++++ 27 files changed, 457 insertions(+), 110 deletions(-) create mode 100644 src/shared/utils/taskPaths.ts diff --git a/README.md b/README.md index 2964cca..b843c14 100644 --- a/README.md +++ b/README.md @@ -186,7 +186,7 @@ takt #6 --auto-pr ### 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`) @@ -201,14 +201,14 @@ takt add #28 #### Execute Tasks (`takt run`) ```bash -# Execute all pending tasks in .takt/tasks/ +# Execute all pending tasks in .takt/tasks.yaml takt run ``` #### Watch Tasks (`takt watch`) ```bash -# Monitor .takt/tasks/ and auto-execute tasks (resident process) +# Monitor .takt/tasks.yaml and auto-execute tasks (resident process) takt watch ``` @@ -225,6 +225,13 @@ takt list --non-interactive --action delete --branch takt/my-branch --yes 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) 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 ├── config.yaml # Project config (current piece, etc.) -├── tasks/ # Pending task files (.yaml, .md) -├── completed/ # Completed tasks and reports +├── tasks/ # Task input directories (.takt/tasks/{slug}/order.md, etc.) +├── tasks.yaml # Pending tasks metadata (task_dir, piece, worktree, etc.) ├── reports/ # Execution reports (auto-generated) │ └── {timestamp}-{slug}/ └── logs/ # NDJSON format session logs @@ -625,32 +632,38 @@ Priority: Environment variables > `config.yaml` settings ## 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 -# .takt/tasks/add-auth.yaml -task: "Add authentication feature" -worktree: true # Execute in isolated shared clone -branch: "feat/add-auth" # Branch name (auto-generated if omitted) -piece: "default" # Piece specification (uses current if omitted) +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 ``` -**Markdown format** (simple, backward compatible): - -```markdown -# .takt/tasks/add-login-feature.md - -Add login feature to the application. - -Requirements: -- Username and password fields -- Form validation -- Error handling on failure -``` +`takt add` creates `.takt/tasks/{slug}/order.md` automatically and saves `task_dir` to `tasks.yaml`. #### Isolated Execution with Shared Clone diff --git a/builtins/project/tasks/TASK-FORMAT b/builtins/project/tasks/TASK-FORMAT index fcf8740..2a9650a 100644 --- a/builtins/project/tasks/TASK-FORMAT +++ b/builtins/project/tasks/TASK-FORMAT @@ -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 - task: "Task description" - worktree: true # (optional) true | "/path/to/dir" - branch: "feat/my-feature" # (optional) branch name - piece: "default" # (optional) piece name + .takt/ + tasks/ + 20260201-015714-foptng/ + order.md + 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: - task (required) Task description (string) - worktree (optional) true: create shared clone, "/path": clone at path - branch (optional) Branch name (auto-generated if omitted: takt/{timestamp}-{slug}) - piece (optional) Piece name (uses current piece if omitted) + task_dir (recommended) Path to task directory that contains `order.md` + content (legacy) Inline task text (kept for compatibility) + content_file (legacy) Path to task text file (kept for compatibility) -## Markdown Format (Simple) +## Command Behavior - # .takt/tasks/my-task.md - - Entire file content becomes the task description. - Supports multiline. No structured options available. - -## Supported Extensions - - .yaml, .yml -> YAML format (parsed and validated) - .md -> Markdown format (plain text, backward compatible) +- `takt add` creates `.takt/tasks/{slug}/order.md` automatically. +- `takt run` and `takt watch` read `.takt/tasks.yaml` and resolve `task_dir`. +- Report output is written to `.takt/reports/{slug}/`. ## Commands - takt /add-task Add a task interactively - takt /run-tasks Run all pending tasks - takt /watch Watch and auto-run tasks - takt /list-tasks List task branches (merge/delete) + takt add Add a task and create task directory + takt run Run all pending tasks in tasks.yaml + takt watch Watch tasks.yaml and run pending tasks + takt list List task branches (merge/delete) diff --git a/docs/README.ja.md b/docs/README.ja.md index c9b689c..38717bc 100644 --- a/docs/README.ja.md +++ b/docs/README.ja.md @@ -186,7 +186,7 @@ takt #6 --auto-pr ### タスク管理(add / run / watch / list) -タスクファイル(`.takt/tasks/`)を使ったバッチ処理。複数のタスクを積んでおいて、後でまとめて実行する使い方に便利です。 +`.takt/tasks.yaml` と `.takt/tasks/{slug}/` を使ったバッチ処理。複数のタスクを積んでおいて、後でまとめて実行する使い方に便利です。 #### タスクを追加(`takt add`) @@ -201,14 +201,14 @@ takt add #28 #### タスクを実行(`takt run`) ```bash -# .takt/tasks/ の保留中タスクをすべて実行 +# .takt/tasks.yaml の保留中タスクをすべて実行 takt run ``` #### タスクを監視(`takt watch`) ```bash -# .takt/tasks/ を監視してタスクを自動実行(常駐プロセス) +# .takt/tasks.yaml を監視してタスクを自動実行(常駐プロセス) takt watch ``` @@ -225,6 +225,13 @@ takt list --non-interactive --action delete --branch takt/my-branch --yes 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/自動化向け) `--pipeline` を指定すると非対話のパイプラインモードに入ります。ブランチ作成 → ピース実行 → commit & push を自動で行います。CI/CD での自動化に適しています。 @@ -532,8 +539,8 @@ Claude Code はエイリアス(`opus`、`sonnet`、`haiku`、`opusplan`、`def .takt/ # プロジェクトレベルの設定 ├── config.yaml # プロジェクト設定(現在のピース等) -├── tasks/ # 保留中のタスクファイル(.yaml, .md) -├── completed/ # 完了したタスクとレポート +├── tasks/ # タスク入力ディレクトリ(.takt/tasks/{slug}/order.md など) +├── tasks.yaml # 保留中タスクのメタデータ(task_dir, piece, worktree など) ├── reports/ # 実行レポート(自動生成) │ └── {timestamp}-{slug}/ └── 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 -# .takt/tasks/add-auth.yaml -task: "認証機能を追加する" -worktree: true # 隔離された共有クローンで実行 -branch: "feat/add-auth" # ブランチ名(省略時は自動生成) -piece: "default" # ピース指定(省略時は現在のもの) +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 ``` -**Markdown形式**(シンプル、後方互換): - -```markdown -# .takt/tasks/add-login-feature.md - -アプリケーションにログイン機能を追加する。 - -要件: -- ユーザー名とパスワードフィールド -- フォームバリデーション -- 失敗時のエラーハンドリング -``` +`takt add` は `.takt/tasks/{slug}/order.md` を自動生成し、`tasks.yaml` には `task_dir` を保存します。 #### 共有クローンによる隔離実行 diff --git a/docs/testing/e2e.md b/docs/testing/e2e.md index 331d1e2..1ebd72c 100644 --- a/docs/testing/e2e.md +++ b/docs/testing/e2e.md @@ -26,13 +26,13 @@ E2Eテストを追加・変更した場合は、このドキュメントも更 ## シナリオ一覧 - 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` の場合に呼び出す) - 手順(ユーザー行動/コマンド): - - `.takt/tasks/e2e-test-task.yaml` にタスクを作成(`piece` は `e2e/fixtures/pieces/simple.yaml` を指定)。 + - `.takt/tasks.yaml` にタスクを作成(`piece` は `e2e/fixtures/pieces/simple.yaml` を指定)。 - `takt run` を実行する。 - `README.md` に行が追加されることを確認する。 - - タスクファイルが `tasks/` から移動されることを確認する。 + - 実行後にタスクが `tasks.yaml` から消えることを確認する。 - Worktree/Clone isolation(`e2e/specs/worktree.e2e.ts`) - 目的: `--create-worktree yes` 指定で隔離環境に実行されることを確認。 - LLM: 条件付き(`TAKT_E2E_PROVIDER` が `claude` / `codex` の場合に呼び出す) @@ -83,13 +83,13 @@ E2Eテストを追加・変更した場合は、このドキュメントも更 - `gh issue create ...` でIssueを作成する。 - `TAKT_MOCK_SCENARIO=e2e/fixtures/scenarios/add-task.json` を設定する。 - `takt add '#'` を実行し、`Create worktree?` に `n` で回答する。 - - `.takt/tasks/` にYAMLが生成されることを確認する。 + - `.takt/tasks.yaml` に `task_dir` が保存され、`.takt/tasks/{slug}/order.md` が生成されることを確認する。 - Watch tasks(`e2e/specs/watch.e2e.ts`) - 目的: `takt watch` が監視中に追加されたタスクを実行できることを確認。 - LLM: 呼び出さない(`--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` が含まれることを確認する。 - `Ctrl+C` で終了する。 - Run tasks graceful shutdown on SIGINT(`e2e/specs/run-sigint-graceful.e2e.ts`) diff --git a/e2e/specs/add.e2e.ts b/e2e/specs/add.e2e.ts index a4bb031..f2f26f5 100644 --- a/e2e/specs/add.e2e.ts +++ b/e2e/specs/add.e2e.ts @@ -1,6 +1,6 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; 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 { fileURLToPath } from 'node:url'; 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 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?.[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); }); diff --git a/src/__tests__/addTask.test.ts b/src/__tests__/addTask.test.ts index 5bba9ba..8b94428 100644 --- a/src/__tests__/addTask.test.ts +++ b/src/__tests__/addTask.test.ts @@ -97,6 +97,10 @@ afterEach(() => { }); 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 () => { mockInteractiveMode.mockResolvedValue({ action: 'execute', task: '# 認証機能追加\nJWT認証を実装する' }); @@ -104,7 +108,9 @@ describe('addTask', () => { const tasks = loadTasks(testDir).tasks; 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'); }); @@ -128,7 +134,8 @@ describe('addTask', () => { expect(mockInteractiveMode).not.toHaveBeenCalled(); 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); }); @@ -153,7 +160,8 @@ describe('addTask', () => { const tasks = loadTasks(testDir).tasks; expect(tasks).toHaveLength(1); 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 () => { diff --git a/src/__tests__/engine-worktree-report.test.ts b/src/__tests__/engine-worktree-report.test.ts index 52b4378..92bb263 100644 --- a/src/__tests__/engine-worktree-report.test.ts +++ b/src/__tests__/engine-worktree-report.test.ts @@ -198,4 +198,47 @@ describe('PieceEngine: worktree reportDir resolution', () => { const expectedPath = join(normalDir, '.takt/reports/test-report-dir'); 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: '); + }); }); diff --git a/src/__tests__/saveTaskFile.test.ts b/src/__tests__/saveTaskFile.test.ts index 0e179e3..f4edfb1 100644 --- a/src/__tests__/saveTaskFile.test.ts +++ b/src/__tests__/saveTaskFile.test.ts @@ -42,10 +42,13 @@ function loadTasks(testDir: string): { tasks: Array> } { beforeEach(() => { vi.clearAllMocks(); + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-02-10T04:40:00.000Z')); testDir = fs.mkdtempSync(path.join(tmpdir(), 'takt-test-save-')); }); afterEach(() => { + vi.useRealTimers(); if (testDir && fs.existsSync(testDir)) { fs.rmSync(testDir, { recursive: true }); } @@ -61,7 +64,11 @@ describe('saveTaskFile', () => { const tasks = loadTasks(testDir).tasks; 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 () => { @@ -79,6 +86,7 @@ describe('saveTaskFile', () => { expect(task.worktree).toBe(true); expect(task.branch).toBe('feat/my-branch'); expect(task.auto_pr).toBe(false); + expect(task.task_dir).toBeTypeOf('string'); }); it('should generate unique names on duplicates', async () => { @@ -86,6 +94,13 @@ describe('saveTaskFile', () => { const second = await saveTaskFile(testDir, 'Same title'); 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'); }); }); diff --git a/src/__tests__/task-schema.test.ts b/src/__tests__/task-schema.test.ts index c0971f2..dc2550c 100644 --- a/src/__tests__/task-schema.test.ts +++ b/src/__tests__/task-schema.test.ts @@ -216,9 +216,39 @@ describe('TaskRecordSchema', () => { 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 }; 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(); + }); }); }); diff --git a/src/__tests__/task.test.ts b/src/__tests__/task.test.ts index 5068f91..d715390 100644 --- a/src/__tests__/task.test.ts +++ b/src/__tests__/task.test.ts @@ -161,14 +161,49 @@ describe('TaskRunner (tasks.yaml)', () => { 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({ - content: 'Inline content', - content_file: 'missing-content-file.txt', + content: undefined, + task_dir: '.takt/tasks/20260201-000000-demo', })]); 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', () => { diff --git a/src/__tests__/taskExecution.test.ts b/src/__tests__/taskExecution.test.ts index 5d5eb24..68a08b1 100644 --- a/src/__tests__/taskExecution.test.ts +++ b/src/__tests__/taskExecution.test.ts @@ -511,4 +511,52 @@ describe('resolveTaskExecution', () => { expect(mockSummarizeTaskName).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/..', + ); + }); }); diff --git a/src/app/cli/commands.ts b/src/app/cli/commands.ts index 0b6ad5d..215e1b8 100644 --- a/src/app/cli/commands.ts +++ b/src/app/cli/commands.ts @@ -15,7 +15,7 @@ import { resolveAgentOverrides } from './helpers.js'; program .command('run') - .description('Run all pending tasks from .takt/tasks/') + .description('Run all pending tasks from .takt/tasks.yaml') .action(async () => { const piece = getCurrentPiece(resolvedCwd); await runAllTasks(resolvedCwd, piece, resolveAgentOverrides(program)); diff --git a/src/core/piece/engine/PieceEngine.ts b/src/core/piece/engine/PieceEngine.ts index ec86e8c..648c672 100644 --- a/src/core/piece/engine/PieceEngine.ts +++ b/src/core/piece/engine/PieceEngine.ts @@ -27,7 +27,7 @@ import { addUserInput as addUserInputToState, incrementMovementIteration, } 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 { MovementExecutor } from './MovementExecutor.js'; import { ParallelRunner } from './ParallelRunner.js'; @@ -79,7 +79,11 @@ export class PieceEngine extends EventEmitter { this.options = options; this.loopDetector = new LoopDetector(config.loopDetection); 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.validateConfig(); this.state = createInitialState(config, options); diff --git a/src/core/piece/types.ts b/src/core/piece/types.ts index 82b681c..f24edd2 100644 --- a/src/core/piece/types.ts +++ b/src/core/piece/types.ts @@ -190,6 +190,8 @@ export interface PieceEngineOptions { startMovement?: string; /** Retry note explaining why task is being retried */ retryNote?: string; + /** Override report directory name (without parent path). */ + reportDirName?: string; /** Task name prefix for parallel task execution output */ taskPrefix?: string; /** Color index for task prefix (cycled across tasks) */ diff --git a/src/features/tasks/add/index.ts b/src/features/tasks/add/index.ts index 6d3ef39..1b80800 100644 --- a/src/features/tasks/add/index.ts +++ b/src/features/tasks/add/index.ts @@ -6,17 +6,30 @@ */ import * as path from 'node:path'; +import * as fs from 'node:fs'; import { promptInput, confirm } from '../../../shared/prompt/index.js'; import { success, info, error } from '../../../shared/ui/index.js'; import { TaskRunner, type TaskFileData } from '../../../infra/task/index.js'; import { getPieceDescription, loadGlobalConfig } from '../../../infra/config/index.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 { interactiveMode } from '../../interactive/index.js'; 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. * @@ -29,6 +42,12 @@ export async function saveTaskFile( options?: { piece?: string; issue?: number; worktree?: boolean | string; branch?: string; autoPr?: boolean }, ): Promise<{ taskName: string; tasksFile: string }> { 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 = { ...(options?.worktree !== undefined && { worktree: options.worktree }), ...(options?.branch && { branch: options.branch }), @@ -36,7 +55,10 @@ export async function saveTaskFile( ...(options?.issue !== undefined && { issue: options.issue }), ...(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'); log.info('Task created', { taskName: created.name, tasksFile, config }); return { taskName: created.name, tasksFile }; diff --git a/src/features/tasks/execute/parallelExecution.ts b/src/features/tasks/execute/parallelExecution.ts index ee65819..2967c56 100644 --- a/src/features/tasks/execute/parallelExecution.ts +++ b/src/features/tasks/execute/parallelExecution.ts @@ -7,7 +7,7 @@ * (concurrency>1) execution through the same code path. * * 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. */ diff --git a/src/features/tasks/execute/pieceExecution.ts b/src/features/tasks/execute/pieceExecution.ts index 99ece9f..6f966a9 100644 --- a/src/features/tasks/execute/pieceExecution.ts +++ b/src/features/tasks/execute/pieceExecution.ts @@ -347,6 +347,7 @@ export async function executePiece( callAiJudge, startMovement: options.startMovement, retryNote: options.retryNote, + reportDirName: options.reportDirName, taskPrefix: options.taskPrefix, taskColorIndex: options.taskColorIndex, }); diff --git a/src/features/tasks/execute/resolveTask.ts b/src/features/tasks/execute/resolveTask.ts index 8a74c93..632b0b7 100644 --- a/src/features/tasks/execute/resolveTask.ts +++ b/src/features/tasks/execute/resolveTask.ts @@ -5,11 +5,13 @@ import { loadGlobalConfig } from '../../../infra/config/index.js'; import { type TaskInfo, createSharedClone, summarizeTaskName, getCurrentBranch } from '../../../infra/task/index.js'; import { info } from '../../../shared/ui/index.js'; +import { getTaskSlugFromTaskDir } from '../../../shared/utils/taskPaths.js'; export interface ResolvedTaskExecution { execCwd: string; execPiece: string; isWorktree: boolean; + reportDirName?: string; branch?: string; baseBranch?: string; startMovement?: string; @@ -44,8 +46,16 @@ export async function resolveTaskExecution( let execCwd = defaultCwd; let isWorktree = false; + let reportDirName: string | undefined; let branch: 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) { throwIfAborted(abortSignal); @@ -80,5 +90,16 @@ export async function resolveTaskExecution( 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 } : {}), + }; } diff --git a/src/features/tasks/execute/taskExecution.ts b/src/features/tasks/execute/taskExecution.ts index 876652c..b0da2ae 100644 --- a/src/features/tasks/execute/taskExecution.ts +++ b/src/features/tasks/execute/taskExecution.ts @@ -49,7 +49,7 @@ function resolveTaskIssue(issueNumber: number | undefined): ReturnType { - 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); if (!pieceConfig) { @@ -80,6 +80,7 @@ async function executeTaskWithResult(options: ExecuteTaskOptions): Promise1) execution through the same code path. diff --git a/src/features/tasks/execute/types.ts b/src/features/tasks/execute/types.ts index 5a8d946..37896cd 100644 --- a/src/features/tasks/execute/types.ts +++ b/src/features/tasks/execute/types.ts @@ -42,6 +42,8 @@ export interface PieceExecutionOptions { startMovement?: string; /** Retry note explaining why task is being retried */ 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 */ abortSignal?: AbortSignal; /** Task name prefix for parallel execution output (e.g. "[task-name] output...") */ @@ -74,6 +76,8 @@ export interface ExecuteTaskOptions { startMovement?: string; /** Retry note explaining why task is being retried */ 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 */ abortSignal?: AbortSignal; /** Task name prefix for parallel execution output (e.g. "[task-name] output...") */ diff --git a/src/features/tasks/list/index.ts b/src/features/tasks/list/index.ts index 1575645..4d26732 100644 --- a/src/features/tasks/list/index.ts +++ b/src/features/tasks/list/index.ts @@ -2,7 +2,7 @@ * List tasks command — main entry point. * * 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. * Task delete actions are in taskDeleteActions.ts. * Non-interactive mode is in listNonInteractive.ts. diff --git a/src/infra/task/mapper.ts b/src/infra/task/mapper.ts index ded75cf..87762b6 100644 --- a/src/infra/task/mapper.ts +++ b/src/infra/task/mapper.ts @@ -7,10 +7,37 @@ function firstLine(content: string): string { 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 { if (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) { throw new Error(`Task content is missing: ${task.name}`); } @@ -40,6 +67,7 @@ export function toTaskInfo(projectDir: string, tasksFile: string, task: TaskReco filePath: tasksFile, name: task.name, content, + taskDir: task.task_dir, createdAt: task.created_at, status: task.status, data: TaskFileSchema.parse({ diff --git a/src/infra/task/runner.ts b/src/infra/task/runner.ts index 4b10ea4..e7e9957 100644 --- a/src/infra/task/runner.ts +++ b/src/infra/task/runner.ts @@ -29,13 +29,17 @@ export class TaskRunner { return this.tasksFile; } - addTask(content: string, options?: Omit): TaskInfo { + addTask( + content: string, + options?: Omit & { content_file?: string; task_dir?: string }, + ): TaskInfo { const state = this.store.update((current) => { const name = this.generateTaskName(content, current.tasks.map((task) => task.name)); + const contentValue = options?.task_dir ? undefined : content; const record: TaskRecord = TaskRecordSchema.parse({ name, status: 'pending', - content, + content: contentValue, created_at: nowIso(), started_at: null, completed_at: null, diff --git a/src/infra/task/schema.ts b/src/infra/task/schema.ts index a884f0f..f84df16 100644 --- a/src/infra/task/schema.ts +++ b/src/infra/task/schema.ts @@ -3,6 +3,7 @@ */ import { z } from 'zod/v4'; +import { isValidTaskDir } from '../../shared/utils/taskPaths.js'; /** * Per-task execution config schema. @@ -40,19 +41,35 @@ export type TaskFailure = z.infer; export const TaskRecordSchema = TaskExecutionConfigSchema.extend({ name: z.string().min(1), status: TaskStatusSchema, - content: z.string().optional(), - content_file: z.string().optional(), + content: z.string().min(1).optional(), + content_file: z.string().min(1).optional(), + task_dir: z.string().optional(), created_at: z.string().min(1), started_at: z.string().nullable(), completed_at: z.string().nullable(), owner_pid: z.number().int().positive().nullable().optional(), failure: TaskFailureSchema.optional(), }).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({ code: z.ZodIssueCode.custom, 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/ format.', }); } diff --git a/src/infra/task/types.ts b/src/infra/task/types.ts index 2968a7c..303ccf6 100644 --- a/src/infra/task/types.ts +++ b/src/infra/task/types.ts @@ -10,6 +10,7 @@ export interface TaskInfo { filePath: string; name: string; content: string; + taskDir?: string; createdAt: string; status: TaskStatus; data: TaskFileData | null; diff --git a/src/shared/utils/index.ts b/src/shared/utils/index.ts index 1eb7ab4..24859d5 100644 --- a/src/shared/utils/index.ts +++ b/src/shared/utils/index.ts @@ -8,6 +8,7 @@ export * from './notification.js'; export * from './reportDir.js'; export * from './sleep.js'; export * from './slug.js'; +export * from './taskPaths.js'; export * from './text.js'; export * from './types.js'; export * from './updateNotifier.js'; diff --git a/src/shared/utils/taskPaths.ts b/src/shared/utils/taskPaths.ts new file mode 100644 index 0000000..b12d582 --- /dev/null +++ b/src/shared/utils/taskPaths.ts @@ -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); +}