add-task
This commit is contained in:
parent
1a77f0060c
commit
87b9ed9d87
@ -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
115
src/commands/addTask.ts
Normal 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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
|||||||
@ -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';
|
||||||
|
|||||||
@ -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 };
|
||||||
|
}
|
||||||
|
|||||||
@ -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> 指定したタスクを実行'));
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
106
src/task/parser.ts
Normal 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;
|
||||||
|
}
|
||||||
@ -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
34
src/task/schema.ts
Normal 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
142
src/task/worktree.ts
Normal 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
18
src/utils/slug.ts
Normal 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);
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user