From 87b9ed9d87a8d2f29887ba6f8b55093be3401fd6 Mon Sep 17 00:00:00 2001 From: nrslib <38722970+nrslib@users.noreply.github.com> Date: Tue, 27 Jan 2026 20:07:47 +0900 Subject: [PATCH] add-task --- src/cli.ts | 7 +- src/commands/addTask.ts | 115 +++++++++++++++++++++++++++ src/commands/help.ts | 7 ++ src/commands/index.ts | 1 + src/commands/taskExecution.ts | 45 ++++++++++- src/task/display.ts | 21 ++++- src/task/index.ts | 4 + src/task/parser.ts | 106 +++++++++++++++++++++++++ src/task/runner.ts | 80 +++++++++---------- src/task/schema.ts | 34 ++++++++ src/task/worktree.ts | 142 ++++++++++++++++++++++++++++++++++ src/utils/slug.ts | 18 +++++ 12 files changed, 533 insertions(+), 47 deletions(-) create mode 100644 src/commands/addTask.ts create mode 100644 src/task/parser.ts create mode 100644 src/task/schema.ts create mode 100644 src/task/worktree.ts create mode 100644 src/utils/slug.ts diff --git a/src/cli.ts b/src/cli.ts index 8ca706a..df5d2e0 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -29,6 +29,7 @@ import { showHelp, switchWorkflow, switchConfig, + addTask, } from './commands/index.js'; import { listWorkflows } from './config/workflowLoader.js'; import { selectOptionWithDefault } from './prompt/index.js'; @@ -104,9 +105,13 @@ program await switchConfig(cwd, args[0]); return; + case 'add-task': + await addTask(cwd, args); + return; + default: error(`Unknown command: /${command}`); - info('Available: /run-tasks, /switch, /clear, /help, /config'); + info('Available: /run-tasks, /add-task, /switch, /clear, /help, /config'); process.exit(1); } } diff --git a/src/commands/addTask.ts b/src/commands/addTask.ts new file mode 100644 index 0000000..c411a17 --- /dev/null +++ b/src/commands/addTask.ts @@ -0,0 +1,115 @@ +/** + * /add-task command implementation + * + * Creates a new task file in .takt/tasks/ with YAML format. + * Supports worktree and branch options. + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { stringify as stringifyYaml } from 'yaml'; +import { promptInput, confirm } from '../prompt/index.js'; +import { success, info, error } from '../utils/ui.js'; +import { slugify } from '../utils/slug.js'; +import { createLogger } from '../utils/debug.js'; +import type { TaskFileData } from '../task/schema.js'; + +const log = createLogger('add-task'); + +/** + * Generate a unique task filename + */ +function generateFilename(tasksDir: string, taskContent: string): string { + const slug = slugify(taskContent); + const base = slug || 'task'; + let filename = `${base}.yaml`; + let counter = 1; + + while (fs.existsSync(path.join(tasksDir, filename))) { + filename = `${base}-${counter}.yaml`; + counter++; + } + + return filename; +} + +/** + * /add-task command handler + * + * Usage: + * takt /add-task "タスク内容" # Quick add (no worktree) + * takt /add-task # Interactive mode + */ +export async function addTask(cwd: string, args: string[]): Promise { + const tasksDir = path.join(cwd, '.takt', 'tasks'); + fs.mkdirSync(tasksDir, { recursive: true }); + + let taskContent: string; + let worktree: boolean | string | undefined; + let branch: string | undefined; + let workflow: string | undefined; + + if (args.length > 0) { + // Argument mode: task content provided directly + taskContent = args.join(' '); + } else { + // Interactive mode + const input = await promptInput('Task content'); + if (!input) { + info('Cancelled.'); + return; + } + taskContent = input; + } + + // Ask about worktree + const useWorktree = await confirm('Create worktree?', false); + if (useWorktree) { + const customPath = await promptInput('Worktree path (Enter for auto)'); + worktree = customPath || true; + + // Ask about branch + const customBranch = await promptInput('Branch name (Enter for auto)'); + if (customBranch) { + branch = customBranch; + } + } + + // Ask about workflow + const customWorkflow = await promptInput('Workflow name (Enter for default)'); + if (customWorkflow) { + workflow = customWorkflow; + } + + // Build task data + const taskData: TaskFileData = { task: taskContent }; + if (worktree !== undefined) { + taskData.worktree = worktree; + } + if (branch) { + taskData.branch = branch; + } + if (workflow) { + taskData.workflow = workflow; + } + + // Write YAML filea + const filename = generateFilename(tasksDir, taskContent); + const filePath = path.join(tasksDir, filename); + const yamlContent = stringifyYaml(taskData); + fs.writeFileSync(filePath, yamlContent, 'utf-8'); + + log.info('Task created', { filePath, taskData }); + + success(`Task created: ${filename}`); + info(` Path: ${filePath}`); + if (worktree) { + info(` Worktree: ${typeof worktree === 'string' ? worktree : 'auto'}`); + } + if (branch) { + info(` Branch: ${branch}`); + } + if (workflow) { + info(` Workflow: ${workflow}`); + } +} diff --git a/src/commands/help.ts b/src/commands/help.ts index b318e88..0a385ca 100644 --- a/src/commands/help.ts +++ b/src/commands/help.ts @@ -15,16 +15,23 @@ export function showHelp(): void { Usage: takt {task} Execute task with current workflow (continues session) takt /run-tasks Run all pending tasks from .takt/tasks/ + takt /add-task Add a new task (interactive, YAML format) takt /switch Switch workflow interactively takt /clear Clear agent conversation sessions (reset to initial state) takt /help Show this help Examples: takt "Fix the bug in main.ts" # Execute task (continues session) + takt /add-task "認証機能を追加する" # Quick add task + takt /add-task # Interactive task creation takt /clear # Clear sessions, start fresh takt /switch takt /run-tasks +Task files (.takt/tasks/): + .md files Plain text tasks (backward compatible) + .yaml files Structured tasks with worktree/branch/workflow options + Configuration (.takt/config.yaml): workflow: default # Current workflow verbose: true # Enable verbose output diff --git a/src/commands/index.ts b/src/commands/index.ts index e8fff32..5b9d6aa 100644 --- a/src/commands/index.ts +++ b/src/commands/index.ts @@ -4,6 +4,7 @@ export { executeWorkflow, type WorkflowExecutionResult, type WorkflowExecutionOptions } from './workflowExecution.js'; export { executeTask, runAllTasks } from './taskExecution.js'; +export { addTask } from './addTask.js'; export { showHelp } from './help.js'; export { withAgentSession } from './session.js'; export { switchWorkflow } from './workflow.js'; diff --git a/src/commands/taskExecution.ts b/src/commands/taskExecution.ts index a7926d6..436d494 100644 --- a/src/commands/taskExecution.ts +++ b/src/commands/taskExecution.ts @@ -3,7 +3,8 @@ */ import { loadWorkflow } from '../config/index.js'; -import { TaskRunner } from '../task/index.js'; +import { TaskRunner, type TaskInfo } from '../task/index.js'; +import { createWorktree } from '../task/worktree.js'; import { header, info, @@ -61,7 +62,7 @@ export async function runAllTasks( if (!task) { info('No pending tasks in .takt/tasks/'); - info('Create task files as .takt/tasks/*.md'); + info('Create task files as .takt/tasks/*.yaml or use takt /add-task'); return; } @@ -79,7 +80,10 @@ export async function runAllTasks( const executionLog: string[] = []; try { - const taskSuccess = await executeTask(task.content, cwd, workflowName); + // Resolve execution directory and workflow from task data + const { execCwd, execWorkflow } = resolveTaskExecution(task, cwd, workflowName); + + const taskSuccess = await executeTask(task.content, execCwd, execWorkflow); const completedAt = new Date().toISOString(); taskRunner.completeTask({ @@ -127,3 +131,38 @@ export async function runAllTasks( status('Failed', String(failCount), 'red'); } } + +/** + * Resolve execution directory and workflow from task data. + * If the task has worktree settings, create a worktree and use it as cwd. + */ +function resolveTaskExecution( + task: TaskInfo, + defaultCwd: string, + defaultWorkflow: string +): { execCwd: string; execWorkflow: string } { + const data = task.data; + + // No structured data: use defaults + if (!data) { + return { execCwd: defaultCwd, execWorkflow: defaultWorkflow }; + } + + let execCwd = defaultCwd; + + // Handle worktree + if (data.worktree) { + const result = createWorktree(defaultCwd, { + worktree: data.worktree, + branch: data.branch, + taskSlug: task.name, + }); + execCwd = result.path; + info(`Worktree created: ${result.path} (branch: ${result.branch})`); + } + + // Handle workflow override + const execWorkflow = data.workflow || defaultWorkflow; + + return { execCwd, execWorkflow }; +} diff --git a/src/task/display.ts b/src/task/display.ts index a536862..a00932d 100644 --- a/src/task/display.ts +++ b/src/task/display.ts @@ -24,7 +24,8 @@ export function showTaskList(runner: TaskRunner): void { if (tasks.length === 0) { console.log(); info('実行待ちのタスクはありません。'); - console.log(chalk.gray(`\n${runner.getTasksDir()}/ にタスクファイル(.md)を配置してください。`)); + console.log(chalk.gray(`\n${runner.getTasksDir()}/ にタスクファイル(.yaml/.md)を配置してください。`)); + console.log(chalk.gray(`または takt /add-task でタスクを追加できます。`)); return; } @@ -37,12 +38,30 @@ export function showTaskList(runner: TaskRunner): void { const firstLine = task.content.trim().split('\n')[0]?.slice(0, 60) ?? ''; console.log(chalk.cyan.bold(` [${i + 1}] ${task.name}`)); console.log(chalk.gray(` ${firstLine}...`)); + + // Show worktree/branch info for YAML tasks + if (task.data) { + const extras: string[] = []; + if (task.data.worktree) { + extras.push(`worktree: ${typeof task.data.worktree === 'string' ? task.data.worktree : 'auto'}`); + } + if (task.data.branch) { + extras.push(`branch: ${task.data.branch}`); + } + if (task.data.workflow) { + extras.push(`workflow: ${task.data.workflow}`); + } + if (extras.length > 0) { + console.log(chalk.dim(` [${extras.join(', ')}]`)); + } + } } } console.log(); divider('=', 60); console.log(chalk.yellow.bold('使用方法:')); + console.log(chalk.gray(' /add-task タスクを追加')); console.log(chalk.gray(' /task run 次のタスクを実行')); console.log(chalk.gray(' /task run 指定したタスクを実行')); } diff --git a/src/task/index.ts b/src/task/index.ts index 263b7e5..046ca19 100644 --- a/src/task/index.ts +++ b/src/task/index.ts @@ -9,3 +9,7 @@ export { } from './runner.js'; export { showTaskList } from './display.js'; + +export { TaskFileSchema, type TaskFileData } from './schema.js'; +export { parseTaskFile, parseTaskFiles, type ParsedTask } from './parser.js'; +export { createWorktree, removeWorktree, type WorktreeOptions, type WorktreeResult } from './worktree.js'; diff --git a/src/task/parser.ts b/src/task/parser.ts new file mode 100644 index 0000000..b832ad2 --- /dev/null +++ b/src/task/parser.ts @@ -0,0 +1,106 @@ +/** + * Task file parser + * + * Supports both YAML (.yaml/.yml) and Markdown (.md) task files. + * YAML files are validated against TaskFileSchema. + * Markdown files are treated as plain text (backward compatible). + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { parse as parseYaml } from 'yaml'; +import { TaskFileSchema, type TaskFileData } from './schema.js'; + +/** Supported task file extensions */ +const YAML_EXTENSIONS = ['.yaml', '.yml']; +const MD_EXTENSIONS = ['.md']; +export const TASK_EXTENSIONS = [...YAML_EXTENSIONS, ...MD_EXTENSIONS]; + +/** Parsed task with optional structured data */ +export interface ParsedTask { + filePath: string; + name: string; + content: string; + createdAt: string; + /** Structured data from YAML files (null for .md files) */ + data: TaskFileData | null; +} + +/** + * Check if a file is a supported task file + */ +export function isTaskFile(filename: string): boolean { + const ext = path.extname(filename).toLowerCase(); + return TASK_EXTENSIONS.includes(ext); +} + +/** + * Check if a file is a YAML task file + */ +function isYamlFile(filename: string): boolean { + const ext = path.extname(filename).toLowerCase(); + return YAML_EXTENSIONS.includes(ext); +} + +/** + * Get the task name from a filename (without extension) + */ +function getTaskName(filename: string): string { + const ext = path.extname(filename); + return path.basename(filename, ext); +} + +/** + * Parse a single task file + * + * @throws Error if YAML parsing or validation fails + */ +export function parseTaskFile(filePath: string): ParsedTask { + const rawContent = fs.readFileSync(filePath, 'utf-8'); + const stat = fs.statSync(filePath); + const filename = path.basename(filePath); + const name = getTaskName(filename); + + if (isYamlFile(filename)) { + const parsed = parseYaml(rawContent) as unknown; + const validated = TaskFileSchema.parse(parsed); + return { + filePath, + name, + content: validated.task, + createdAt: stat.birthtime.toISOString(), + data: validated, + }; + } + + // Markdown file: plain text, no structured data + return { + filePath, + name, + content: rawContent, + createdAt: stat.birthtime.toISOString(), + data: null, + }; +} + +/** + * List and parse all task files in a directory + */ +export function parseTaskFiles(tasksDir: string): ParsedTask[] { + const tasks: ParsedTask[] = []; + + const files = fs.readdirSync(tasksDir) + .filter(isTaskFile) + .sort(); + + for (const file of files) { + const filePath = path.join(tasksDir, file); + try { + tasks.push(parseTaskFile(filePath)); + } catch { + // Skip files that fail to parse + } + } + + return tasks; +} diff --git a/src/task/runner.ts b/src/task/runner.ts index cbfaf08..1d69c23 100644 --- a/src/task/runner.ts +++ b/src/task/runner.ts @@ -4,6 +4,8 @@ * .takt/tasks/ ディレクトリ内のタスクファイルを読み込み、 * 順番に実行してレポートを生成する。 * + * Supports both .md (plain text) and .yaml/.yml (structured) task files. + * * 使用方法: * /task # タスク一覧を表示 * /task run # 次のタスクを実行 @@ -13,6 +15,8 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; +import { parseTaskFiles, parseTaskFile, isTaskFile, type ParsedTask } from './parser.js'; +import type { TaskFileData } from './schema.js'; /** タスク情報 */ export interface TaskInfo { @@ -20,6 +24,8 @@ export interface TaskInfo { name: string; content: string; createdAt: string; + /** Structured data from YAML files (null for .md files) */ + data: TaskFileData | null; } /** タスク実行結果 */ @@ -63,66 +69,44 @@ export class TaskRunner { */ listTasks(): TaskInfo[] { this.ensureDirs(); - const tasks: TaskInfo[] = []; try { - const files = fs.readdirSync(this.tasksDir) - .filter(f => f.endsWith('.md')) - .sort(); - - for (const file of files) { - const filePath = path.join(this.tasksDir, file); - try { - const content = fs.readFileSync(filePath, 'utf-8'); - const stat = fs.statSync(filePath); - tasks.push({ - filePath, - name: path.basename(file, '.md'), - content, - createdAt: stat.birthtime.toISOString(), - }); - } catch { - // ファイル読み込みエラーはスキップ - } - } + const parsed = parseTaskFiles(this.tasksDir); + return parsed.map(toTaskInfo); } catch (err) { const nodeErr = err as NodeJS.ErrnoException; if (nodeErr.code !== 'ENOENT') { throw err; // 予期しないエラーは再スロー } // ENOENT は許容(ディレクトリ未作成) + return []; } - - return tasks; } /** * 指定した名前のタスクを取得 + * Searches for .yaml, .yml, and .md files in that order. */ getTask(name: string): TaskInfo | null { this.ensureDirs(); - const filePath = path.join(this.tasksDir, `${name}.md`); - if (!fs.existsSync(filePath)) { - return null; - } + const extensions = ['.yaml', '.yml', '.md']; - try { - const content = fs.readFileSync(filePath, 'utf-8'); - const stat = fs.statSync(filePath); - return { - filePath, - name, - content, - createdAt: stat.birthtime.toISOString(), - }; - } catch (err) { - const nodeErr = err as NodeJS.ErrnoException; - if (nodeErr.code !== 'ENOENT') { - throw err; // 予期しないエラーは再スロー + for (const ext of extensions) { + const filePath = path.join(this.tasksDir, `${name}${ext}`); + if (!fs.existsSync(filePath)) { + continue; + } + + try { + const parsed = parseTaskFile(filePath); + return toTaskInfo(parsed); + } catch { + // Parse error: skip this extension } - return null; } + + return null; } /** @@ -154,8 +138,9 @@ export class TaskRunner { ); fs.mkdirSync(taskCompletedDir, { recursive: true }); - // 元のタスクファイルを移動 - const completedTaskFile = path.join(taskCompletedDir, `${result.task.name}.md`); + // 元のタスクファイルを移動(元の拡張子を保持) + const originalExt = path.extname(result.task.filePath); + const completedTaskFile = path.join(taskCompletedDir, `${result.task.name}${originalExt}`); fs.renameSync(result.task.filePath, completedTaskFile); // レポートを生成 @@ -209,3 +194,14 @@ ${result.response} `; } } + +/** Convert ParsedTask to TaskInfo */ +function toTaskInfo(parsed: ParsedTask): TaskInfo { + return { + filePath: parsed.filePath, + name: parsed.name, + content: parsed.content, + createdAt: parsed.createdAt, + data: parsed.data, + }; +} diff --git a/src/task/schema.ts b/src/task/schema.ts new file mode 100644 index 0000000..bc0c7ce --- /dev/null +++ b/src/task/schema.ts @@ -0,0 +1,34 @@ +/** + * Task YAML schema definition + * + * Zod schema for structured task files (.yaml/.yml) + */ + +import { z } from 'zod/v4'; + +/** + * YAML task file schema + * + * Examples: + * task: "認証機能を追加する" + * worktree: true # .takt/worktrees/{timestamp}-{task-slug}/ に作成 + * branch: "feat/add-auth" # オプション(省略時は自動生成) + * workflow: "default" # オプション(省略時はcurrent workflow) + * + * worktree patterns: + * - true: create at .takt/worktrees/{timestamp}-{task-slug}/ + * - "/path/to/dir": create at specified path + * - omitted: no worktree (run in cwd) + * + * branch patterns: + * - "feat/xxx": use specified branch name + * - omitted: auto-generate as takt/{timestamp}-{task-slug} + */ +export const TaskFileSchema = z.object({ + task: z.string().min(1), + worktree: z.union([z.boolean(), z.string()]).optional(), + branch: z.string().optional(), + workflow: z.string().optional(), +}); + +export type TaskFileData = z.infer; diff --git a/src/task/worktree.ts b/src/task/worktree.ts new file mode 100644 index 0000000..81240c6 --- /dev/null +++ b/src/task/worktree.ts @@ -0,0 +1,142 @@ +/** + * Git worktree management + * + * Creates and removes git worktrees for task isolation. + */ + +import * as fs from 'node:fs'; +import * as path from 'node:path'; +import { execFileSync } from 'node:child_process'; +import { createLogger } from '../utils/debug.js'; +import { slugify } from '../utils/slug.js'; +import { isPathSafe } from '../config/paths.js'; + +const log = createLogger('worktree'); + +export interface WorktreeOptions { + /** worktree setting: true = auto path, string = custom path */ + worktree: boolean | string; + /** Branch name (optional, auto-generated if omitted) */ + branch?: string; + /** Task slug for auto-generated paths/branches */ + taskSlug: string; +} + +export interface WorktreeResult { + /** Absolute path to the worktree */ + path: string; + /** Branch name used */ + branch: string; +} + +/** + * Generate a timestamp string for paths/branches + */ +function generateTimestamp(): string { + return new Date().toISOString().replace(/[:.]/g, '').slice(0, 15); +} + +/** + * Resolve the worktree path based on options. + * Validates that the resolved path stays within the project directory. + * + * @throws Error if the resolved path escapes projectDir (path traversal) + */ +function resolveWorktreePath(projectDir: string, options: WorktreeOptions): string { + if (typeof options.worktree === 'string') { + const resolved = path.isAbsolute(options.worktree) + ? options.worktree + : path.resolve(projectDir, options.worktree); + + if (!isPathSafe(projectDir, resolved)) { + throw new Error(`Worktree path escapes project directory: ${options.worktree}`); + } + + return resolved; + } + + // worktree: true → .takt/worktrees/{timestamp}-{task-slug}/ + const timestamp = generateTimestamp(); + const slug = slugify(options.taskSlug); + const dirName = slug ? `${timestamp}-${slug}` : timestamp; + return path.join(projectDir, '.takt', 'worktrees', dirName); +} + +/** + * Resolve the branch name based on options + */ +function resolveBranchName(options: WorktreeOptions): string { + if (options.branch) { + return options.branch; + } + + // Auto-generate: takt/{timestamp}-{task-slug} + const timestamp = generateTimestamp(); + const slug = slugify(options.taskSlug); + return slug ? `takt/${timestamp}-${slug}` : `takt/${timestamp}`; +} + +/** + * Check if a git branch exists + */ +function branchExists(projectDir: string, branch: string): boolean { + try { + execFileSync('git', ['rev-parse', '--verify', branch], { + cwd: projectDir, + stdio: 'pipe', + }); + return true; + } catch { + return false; + } +} + +/** + * Create a git worktree for a task + * + * @returns WorktreeResult with path and branch + * @throws Error if git worktree creation fails + */ +export function createWorktree(projectDir: string, options: WorktreeOptions): WorktreeResult { + const worktreePath = resolveWorktreePath(projectDir, options); + const branch = resolveBranchName(options); + + log.info('Creating worktree', { path: worktreePath, branch }); + + // Ensure parent directory exists + fs.mkdirSync(path.dirname(worktreePath), { recursive: true }); + + // Create worktree (use execFileSync to avoid shell injection) + if (branchExists(projectDir, branch)) { + execFileSync('git', ['worktree', 'add', worktreePath, branch], { + cwd: projectDir, + stdio: 'pipe', + }); + } else { + execFileSync('git', ['worktree', 'add', '-b', branch, worktreePath], { + cwd: projectDir, + stdio: 'pipe', + }); + } + + log.info('Worktree created', { path: worktreePath, branch }); + + return { path: worktreePath, branch }; +} + +/** + * Remove a git worktree + */ +export function removeWorktree(projectDir: string, worktreePath: string): void { + log.info('Removing worktree', { path: worktreePath }); + + try { + execFileSync('git', ['worktree', 'remove', worktreePath, '--force'], { + cwd: projectDir, + stdio: 'pipe', + }); + log.info('Worktree removed', { path: worktreePath }); + } catch (err) { + log.error('Failed to remove worktree', { path: worktreePath, error: String(err) }); + } +} diff --git a/src/utils/slug.ts b/src/utils/slug.ts new file mode 100644 index 0000000..6bf6440 --- /dev/null +++ b/src/utils/slug.ts @@ -0,0 +1,18 @@ +/** + * Text slugification utility + * + * Converts text into URL/filename-safe slugs. + * Supports ASCII alphanumerics and CJK characters. + */ + +/** + * Convert text into a slug for use in filenames, paths, and branch names. + * Preserves CJK characters (U+3000-9FFF, FF00-FFEF). + */ +export function slugify(text: string): string { + return text + .toLowerCase() + .replace(/[^a-z0-9\u3000-\u9fff\uff00-\uffef]+/g, '-') + .replace(/^-+|-+$/g, '') + .slice(0, 50); +}