takt: github-issue-204-takt-tasks (#205)

This commit is contained in:
nrs 2026-02-10 14:26:37 +09:00 committed by GitHub
parent 25f4bf6e2b
commit 8cb3c87801
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
27 changed files with 457 additions and 110 deletions

View File

@ -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

View File

@ -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)

View File

@ -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
アプリケーションにログイン機能を追加する。
要件:
- ユーザー名とパスワードフィールド
- フォームバリデーション
- 失敗時のエラーハンドリング
```
#### 共有クローンによる隔離実行 #### 共有クローンによる隔離実行

View File

@ -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`

View File

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

View File

@ -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 () => {

View File

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

View File

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

View File

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

View File

@ -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', () => {

View File

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

View File

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

View File

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

View File

@ -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) */

View File

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

View File

@ -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.
*/ */

View File

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

View File

@ -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 } : {}),
};
} }

View File

@ -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.

View File

@ -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...") */

View File

@ -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.

View File

@ -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({

View File

@ -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,

View File

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

View File

@ -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;

View File

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

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