diff --git a/resources/project/.gitignore b/resources/project/dotgitignore similarity index 100% rename from resources/project/.gitignore rename to resources/project/dotgitignore diff --git a/resources/project/tasks/TASK-FORMAT b/resources/project/tasks/TASK-FORMAT new file mode 100644 index 0000000..9d31e80 --- /dev/null +++ b/resources/project/tasks/TASK-FORMAT @@ -0,0 +1,37 @@ +TAKT Task File Format +===================== + +Tasks placed in this directory (.takt/tasks/) will be processed by TAKT. + +## YAML Format (Recommended) + + # .takt/tasks/my-task.yaml + task: "Task description" + worktree: true # (optional) true | "/path/to/dir" + branch: "feat/my-feature" # (optional) branch name + workflow: "default" # (optional) workflow name + +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}) + workflow (optional) Workflow name (uses current workflow if omitted) + +## Markdown Format (Simple) + + # .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) + +## 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) diff --git a/src/__tests__/initialization.test.ts b/src/__tests__/initialization.test.ts index b8acb2c..c8fd87e 100644 --- a/src/__tests__/initialization.test.ts +++ b/src/__tests__/initialization.test.ts @@ -27,7 +27,7 @@ vi.mock('../prompt/index.js', () => ({ // Import after mocks are set up const { needsLanguageSetup } = await import('../config/initialization.js'); const { getGlobalAgentsDir, getGlobalWorkflowsDir } = await import('../config/paths.js'); -const { copyLanguageResourcesToDir, getLanguageResourcesDir } = await import('../resources/index.js'); +const { copyLanguageResourcesToDir, copyProjectResourcesToDir, getLanguageResourcesDir, getProjectResourcesDir } = await import('../resources/index.js'); describe('initialization', () => { beforeEach(() => { @@ -87,6 +87,43 @@ describe('initialization', () => { }); }); +describe('copyProjectResourcesToDir', () => { + const testProjectDir = join(tmpdir(), `takt-project-test-${Date.now()}`); + + beforeEach(() => { + mkdirSync(testProjectDir, { recursive: true }); + }); + + afterEach(() => { + if (existsSync(testProjectDir)) { + rmSync(testProjectDir, { recursive: true }); + } + }); + + it('should rename dotgitignore to .gitignore during copy', () => { + const resourcesDir = getProjectResourcesDir(); + if (!existsSync(join(resourcesDir, 'dotgitignore'))) { + return; // Skip if resource file doesn't exist + } + + copyProjectResourcesToDir(testProjectDir); + + expect(existsSync(join(testProjectDir, '.gitignore'))).toBe(true); + expect(existsSync(join(testProjectDir, 'dotgitignore'))).toBe(false); + }); + + it('should copy tasks/TASK-FORMAT to target directory', () => { + const resourcesDir = getProjectResourcesDir(); + if (!existsSync(join(resourcesDir, 'tasks', 'TASK-FORMAT'))) { + return; // Skip if resource file doesn't exist + } + + copyProjectResourcesToDir(testProjectDir); + + expect(existsSync(join(testProjectDir, 'tasks', 'TASK-FORMAT'))).toBe(true); + }); +}); + describe('getLanguageResourcesDir', () => { it('should return correct path for English', () => { const path = getLanguageResourcesDir('en'); diff --git a/src/__tests__/task.test.ts b/src/__tests__/task.test.ts index 35dc51b..dac5eae 100644 --- a/src/__tests__/task.test.ts +++ b/src/__tests__/task.test.ts @@ -6,6 +6,52 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { mkdirSync, writeFileSync, existsSync, rmSync, readFileSync, readdirSync } from 'node:fs'; import { join } from 'node:path'; import { TaskRunner } from '../task/runner.js'; +import { isTaskFile, parseTaskFiles } from '../task/parser.js'; + +describe('isTaskFile', () => { + it('should accept .yaml files', () => { + expect(isTaskFile('task.yaml')).toBe(true); + }); + + it('should accept .yml files', () => { + expect(isTaskFile('task.yml')).toBe(true); + }); + + it('should accept .md files', () => { + expect(isTaskFile('task.md')).toBe(true); + }); + + it('should reject extensionless files like TASK-FORMAT', () => { + expect(isTaskFile('TASK-FORMAT')).toBe(false); + }); + + it('should reject .txt files', () => { + expect(isTaskFile('readme.txt')).toBe(false); + }); +}); + +describe('parseTaskFiles', () => { + const testDir = `/tmp/takt-parse-test-${Date.now()}`; + + beforeEach(() => { + mkdirSync(testDir, { recursive: true }); + }); + + afterEach(() => { + if (existsSync(testDir)) { + rmSync(testDir, { recursive: true, force: true }); + } + }); + + it('should ignore extensionless files like TASK-FORMAT', () => { + writeFileSync(join(testDir, 'TASK-FORMAT'), 'Format documentation'); + writeFileSync(join(testDir, 'real-task.md'), 'Real task'); + + const tasks = parseTaskFiles(testDir); + expect(tasks).toHaveLength(1); + expect(tasks[0]?.name).toBe('real-task'); + }); +}); describe('TaskRunner', () => { const testDir = `/tmp/takt-task-test-${Date.now()}`; diff --git a/src/resources/index.ts b/src/resources/index.ts index d5bd0da..2ab325e 100644 --- a/src/resources/index.ts +++ b/src/resources/index.ts @@ -67,7 +67,9 @@ export function copyProjectResourcesToDir(targetDir: string): void { if (!existsSync(resourcesDir)) { return; } - copyDirRecursive(resourcesDir, targetDir); + copyDirRecursive(resourcesDir, targetDir, { + renameMap: { dotgitignore: '.gitignore' }, + }); } /** @@ -146,6 +148,8 @@ interface CopyOptions { overwrite?: boolean; /** Collect copied file paths into this array */ copiedFiles?: string[]; + /** Rename files during copy (source name → dest name) */ + renameMap?: Record; } /** @@ -154,7 +158,7 @@ interface CopyOptions { * If true, overwrites existing files. */ function copyDirRecursive(srcDir: string, destDir: string, options: CopyOptions = {}): void { - const { skipDirs = [], overwrite = false, copiedFiles } = options; + const { skipDirs = [], overwrite = false, copiedFiles, renameMap } = options; if (!existsSync(destDir)) { mkdirSync(destDir, { recursive: true }); @@ -165,7 +169,8 @@ function copyDirRecursive(srcDir: string, destDir: string, options: CopyOptions if (skipDirs.includes(entry)) continue; const srcPath = join(srcDir, entry); - const destPath = join(destDir, entry); + const destName = renameMap?.[entry] ?? entry; + const destPath = join(destDir, destName); const stat = statSync(srcPath); if (stat.isDirectory()) {