This commit is contained in:
nrslib 2026-01-27 20:07:47 +09:00
parent 1a77f0060c
commit 87b9ed9d87
12 changed files with 533 additions and 47 deletions

View File

@ -29,6 +29,7 @@ import {
showHelp, showHelp,
switchWorkflow, switchWorkflow,
switchConfig, switchConfig,
addTask,
} from './commands/index.js'; } from './commands/index.js';
import { listWorkflows } from './config/workflowLoader.js'; import { listWorkflows } from './config/workflowLoader.js';
import { selectOptionWithDefault } from './prompt/index.js'; import { selectOptionWithDefault } from './prompt/index.js';
@ -104,9 +105,13 @@ program
await switchConfig(cwd, args[0]); await switchConfig(cwd, args[0]);
return; return;
case 'add-task':
await addTask(cwd, args);
return;
default: default:
error(`Unknown command: /${command}`); 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); process.exit(1);
} }
} }

115
src/commands/addTask.ts Normal file
View File

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

View File

@ -15,16 +15,23 @@ export function showHelp(): void {
Usage: Usage:
takt {task} Execute task with current workflow (continues session) takt {task} Execute task with current workflow (continues session)
takt /run-tasks Run all pending tasks from .takt/tasks/ 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 /switch Switch workflow interactively
takt /clear Clear agent conversation sessions (reset to initial state) takt /clear Clear agent conversation sessions (reset to initial state)
takt /help Show this help takt /help Show this help
Examples: Examples:
takt "Fix the bug in main.ts" # Execute task (continues session) 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 /clear # Clear sessions, start fresh
takt /switch takt /switch
takt /run-tasks 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): Configuration (.takt/config.yaml):
workflow: default # Current workflow workflow: default # Current workflow
verbose: true # Enable verbose output verbose: true # Enable verbose output

View File

@ -4,6 +4,7 @@
export { executeWorkflow, type WorkflowExecutionResult, type WorkflowExecutionOptions } from './workflowExecution.js'; export { executeWorkflow, type WorkflowExecutionResult, type WorkflowExecutionOptions } from './workflowExecution.js';
export { executeTask, runAllTasks } from './taskExecution.js'; export { executeTask, runAllTasks } from './taskExecution.js';
export { addTask } from './addTask.js';
export { showHelp } from './help.js'; export { showHelp } from './help.js';
export { withAgentSession } from './session.js'; export { withAgentSession } from './session.js';
export { switchWorkflow } from './workflow.js'; export { switchWorkflow } from './workflow.js';

View File

@ -3,7 +3,8 @@
*/ */
import { loadWorkflow } from '../config/index.js'; 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 { import {
header, header,
info, info,
@ -61,7 +62,7 @@ export async function runAllTasks(
if (!task) { if (!task) {
info('No pending tasks in .takt/tasks/'); 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; return;
} }
@ -79,7 +80,10 @@ export async function runAllTasks(
const executionLog: string[] = []; const executionLog: string[] = [];
try { 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(); const completedAt = new Date().toISOString();
taskRunner.completeTask({ taskRunner.completeTask({
@ -127,3 +131,38 @@ export async function runAllTasks(
status('Failed', String(failCount), 'red'); 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 };
}

View File

@ -24,7 +24,8 @@ export function showTaskList(runner: TaskRunner): void {
if (tasks.length === 0) { if (tasks.length === 0) {
console.log(); console.log();
info('実行待ちのタスクはありません。'); 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; return;
} }
@ -37,12 +38,30 @@ export function showTaskList(runner: TaskRunner): void {
const firstLine = task.content.trim().split('\n')[0]?.slice(0, 60) ?? ''; const firstLine = task.content.trim().split('\n')[0]?.slice(0, 60) ?? '';
console.log(chalk.cyan.bold(` [${i + 1}] ${task.name}`)); console.log(chalk.cyan.bold(` [${i + 1}] ${task.name}`));
console.log(chalk.gray(` ${firstLine}...`)); 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(); console.log();
divider('=', 60); divider('=', 60);
console.log(chalk.yellow.bold('使用方法:')); console.log(chalk.yellow.bold('使用方法:'));
console.log(chalk.gray(' /add-task タスクを追加'));
console.log(chalk.gray(' /task run 次のタスクを実行')); console.log(chalk.gray(' /task run 次のタスクを実行'));
console.log(chalk.gray(' /task run <name> 指定したタスクを実行')); console.log(chalk.gray(' /task run <name> 指定したタスクを実行'));
} }

View File

@ -9,3 +9,7 @@ export {
} from './runner.js'; } from './runner.js';
export { showTaskList } from './display.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';

106
src/task/parser.ts Normal file
View File

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

View File

@ -4,6 +4,8 @@
* .takt/tasks/ * .takt/tasks/
* *
* *
* Supports both .md (plain text) and .yaml/.yml (structured) task files.
*
* 使: * 使:
* /task # * /task #
* /task run # * /task run #
@ -13,6 +15,8 @@
import * as fs from 'node:fs'; import * as fs from 'node:fs';
import * as path from 'node:path'; import * as path from 'node:path';
import { parseTaskFiles, parseTaskFile, isTaskFile, type ParsedTask } from './parser.js';
import type { TaskFileData } from './schema.js';
/** タスク情報 */ /** タスク情報 */
export interface TaskInfo { export interface TaskInfo {
@ -20,6 +24,8 @@ export interface TaskInfo {
name: string; name: string;
content: string; content: string;
createdAt: string; createdAt: string;
/** Structured data from YAML files (null for .md files) */
data: TaskFileData | null;
} }
/** タスク実行結果 */ /** タスク実行結果 */
@ -63,66 +69,44 @@ export class TaskRunner {
*/ */
listTasks(): TaskInfo[] { listTasks(): TaskInfo[] {
this.ensureDirs(); this.ensureDirs();
const tasks: TaskInfo[] = [];
try { try {
const files = fs.readdirSync(this.tasksDir) const parsed = parseTaskFiles(this.tasksDir);
.filter(f => f.endsWith('.md')) return parsed.map(toTaskInfo);
.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 {
// ファイル読み込みエラーはスキップ
}
}
} catch (err) { } catch (err) {
const nodeErr = err as NodeJS.ErrnoException; const nodeErr = err as NodeJS.ErrnoException;
if (nodeErr.code !== 'ENOENT') { if (nodeErr.code !== 'ENOENT') {
throw err; // 予期しないエラーは再スロー throw err; // 予期しないエラーは再スロー
} }
// ENOENT は許容(ディレクトリ未作成) // ENOENT は許容(ディレクトリ未作成)
return [];
} }
return tasks;
} }
/** /**
* *
* Searches for .yaml, .yml, and .md files in that order.
*/ */
getTask(name: string): TaskInfo | null { getTask(name: string): TaskInfo | null {
this.ensureDirs(); this.ensureDirs();
const filePath = path.join(this.tasksDir, `${name}.md`);
if (!fs.existsSync(filePath)) { const extensions = ['.yaml', '.yml', '.md'];
return null;
}
try { for (const ext of extensions) {
const content = fs.readFileSync(filePath, 'utf-8'); const filePath = path.join(this.tasksDir, `${name}${ext}`);
const stat = fs.statSync(filePath); if (!fs.existsSync(filePath)) {
return { continue;
filePath, }
name,
content, try {
createdAt: stat.birthtime.toISOString(), const parsed = parseTaskFile(filePath);
}; return toTaskInfo(parsed);
} catch (err) { } catch {
const nodeErr = err as NodeJS.ErrnoException; // Parse error: skip this extension
if (nodeErr.code !== 'ENOENT') {
throw err; // 予期しないエラーは再スロー
} }
return null;
} }
return null;
} }
/** /**
@ -154,8 +138,9 @@ export class TaskRunner {
); );
fs.mkdirSync(taskCompletedDir, { recursive: true }); 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); 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,
};
}

34
src/task/schema.ts Normal file
View File

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

142
src/task/worktree.ts Normal file
View File

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

18
src/utils/slug.ts Normal file
View File

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