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)
|
||||
|
||||
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
|
||||
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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` を保存します。
|
||||
|
||||
#### 共有クローンによる隔離実行
|
||||
|
||||
|
||||
@ -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 '#<issue>'` を実行し、`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`)
|
||||
|
||||
@ -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);
|
||||
});
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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: ');
|
||||
});
|
||||
});
|
||||
|
||||
@ -42,10 +42,13 @@ function loadTasks(testDir: string): { tasks: Array<Record<string, unknown>> } {
|
||||
|
||||
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');
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@ -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', () => {
|
||||
|
||||
@ -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/..',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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));
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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) */
|
||||
|
||||
@ -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<TaskFileData, 'task'> = {
|
||||
...(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 };
|
||||
|
||||
@ -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.
|
||||
*/
|
||||
|
||||
|
||||
@ -347,6 +347,7 @@ export async function executePiece(
|
||||
callAiJudge,
|
||||
startMovement: options.startMovement,
|
||||
retryNote: options.retryNote,
|
||||
reportDirName: options.reportDirName,
|
||||
taskPrefix: options.taskPrefix,
|
||||
taskColorIndex: options.taskColorIndex,
|
||||
});
|
||||
|
||||
@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@ -49,7 +49,7 @@ function resolveTaskIssue(issueNumber: number | undefined): ReturnType<typeof fe
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
if (!pieceConfig) {
|
||||
@ -80,6 +80,7 @@ async function executeTaskWithResult(options: ExecuteTaskOptions): Promise<Piece
|
||||
interactiveMetadata,
|
||||
startMovement,
|
||||
retryNote,
|
||||
reportDirName,
|
||||
abortSignal,
|
||||
taskPrefix,
|
||||
taskColorIndex,
|
||||
@ -128,7 +129,7 @@ export async function executeAndCompleteTask(
|
||||
}
|
||||
|
||||
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
|
||||
const taskRunResult = await executeTaskWithResult({
|
||||
@ -139,6 +140,7 @@ export async function executeAndCompleteTask(
|
||||
agentOverrides: options,
|
||||
startMovement,
|
||||
retryNote,
|
||||
reportDirName,
|
||||
abortSignal: taskAbortSignal,
|
||||
taskPrefix: parallelOptions?.taskPrefix,
|
||||
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
|
||||
* (concurrency>1) execution through the same code path.
|
||||
|
||||
@ -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...") */
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -29,13 +29,17 @@ export class TaskRunner {
|
||||
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 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,
|
||||
|
||||
@ -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<typeof TaskFailureSchema>;
|
||||
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/<slug> format.',
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -10,6 +10,7 @@ export interface TaskInfo {
|
||||
filePath: string;
|
||||
name: string;
|
||||
content: string;
|
||||
taskDir?: string;
|
||||
createdAt: string;
|
||||
status: TaskStatus;
|
||||
data: TaskFileData | null;
|
||||
|
||||
@ -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';
|
||||
|
||||
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