takt: takt-list (#310)

This commit is contained in:
nrs 2026-02-19 17:20:22 +09:00 committed by GitHub
parent 80a79683ac
commit 43f6fa6ade
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
23 changed files with 138 additions and 141 deletions

View File

@ -1,11 +1,11 @@
/**
* Unit tests for task naming utilities
*
* Tests nowIso, firstLine, and sanitizeTaskName functions.
* Tests nowIso and firstLine functions.
*/
import { describe, it, expect, vi, afterEach } from 'vitest';
import { nowIso, firstLine, sanitizeTaskName } from '../infra/task/naming.js';
import { nowIso, firstLine } from '../infra/task/naming.js';
describe('nowIso', () => {
afterEach(() => {
@ -54,34 +54,3 @@ describe('firstLine', () => {
expect(firstLine(' \n ')).toBe('');
});
});
describe('sanitizeTaskName', () => {
it('should lowercase the input', () => {
expect(sanitizeTaskName('Hello World')).toBe('hello-world');
});
it('should replace special characters with spaces then hyphens', () => {
expect(sanitizeTaskName('task@name#123')).toBe('task-name-123');
});
it('should collapse multiple hyphens', () => {
expect(sanitizeTaskName('a---b')).toBe('a-b');
});
it('should trim leading/trailing whitespace', () => {
expect(sanitizeTaskName(' hello ')).toBe('hello');
});
it('should handle typical task names', () => {
expect(sanitizeTaskName('Fix: login bug (#42)')).toBe('fix-login-bug-42');
});
it('should generate fallback name for empty result', () => {
const result = sanitizeTaskName('!@#$%');
expect(result).toMatch(/^task-\d+$/);
});
it('should preserve numbers and lowercase letters', () => {
expect(sanitizeTaskName('abc123def')).toBe('abc123def');
});
});

View File

@ -37,12 +37,13 @@ describe('generateReportDir', () => {
vi.useRealTimers();
});
it('should preserve Japanese characters in summary', () => {
it('should strip CJK characters from summary', () => {
vi.useFakeTimers();
vi.setSystemTime(new Date('2025-06-01T12:00:00.000Z'));
const result = generateReportDir('タスク指示書の実装');
expect(result).toContain('タスク指示書の実装');
// CJK characters are removed by slugify, leaving empty → falls back to 'task'
expect(result).toBe('20250601-120000-task');
vi.useRealTimers();
});
@ -53,7 +54,7 @@ describe('generateReportDir', () => {
const result = generateReportDir('Fix: bug (#42)');
const slug = result.replace(/^20250101-000000-/, '');
expect(slug).not.toMatch(/[^a-z0-9\u3040-\u309f\u30a0-\u30ff\u4e00-\u9faf-]/);
expect(slug).not.toMatch(/[^a-z0-9-]/);
vi.useRealTimers();
});

View File

@ -66,6 +66,8 @@ describe('saveTaskFile', () => {
expect(tasks).toHaveLength(1);
expect(tasks[0]?.content).toBeUndefined();
expect(tasks[0]?.task_dir).toBeTypeOf('string');
expect(tasks[0]?.slug).toBeTypeOf('string');
expect(tasks[0]?.summary).toBe('Implement feature X');
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');

View File

@ -1,7 +1,7 @@
/**
* Unit tests for slugify utility
*
* Tests URL/filename-safe slug generation with CJK support.
* Tests URL/filename-safe slug generation (a-z 0-9 hyphen, max 30 chars).
*/
import { describe, it, expect } from 'vitest';
@ -25,17 +25,17 @@ describe('slugify', () => {
expect(slugify(' hello ')).toBe('hello');
});
it('should truncate to 50 characters', () => {
it('should truncate to 30 characters', () => {
const long = 'a'.repeat(100);
expect(slugify(long).length).toBeLessThanOrEqual(50);
expect(slugify(long).length).toBeLessThanOrEqual(30);
});
it('should preserve CJK characters', () => {
expect(slugify('タスク指示書')).toBe('タスク指示書');
it('should strip CJK characters', () => {
expect(slugify('タスク指示書')).toBe('');
});
it('should handle mixed ASCII and CJK', () => {
expect(slugify('Add タスク Feature')).toBe('add-タスク-feature');
expect(slugify('Add タスク Feature')).toBe('add-feature');
});
it('should handle numbers', () => {
@ -43,11 +43,18 @@ describe('slugify', () => {
});
it('should handle empty result after stripping', () => {
// All special characters → becomes empty string
expect(slugify('!@#$%')).toBe('');
});
it('should handle typical GitHub issue titles', () => {
expect(slugify('Fix: login not working (#42)')).toBe('fix-login-not-working-42');
});
it('should strip trailing hyphen after truncation', () => {
// 30 chars of slug that ends with a hyphen after slice
const input = 'abcdefghijklmnopqrstuvwxyz-abc-xyz';
const result = slugify(input);
expect(result.length).toBeLessThanOrEqual(30);
expect(result).not.toMatch(/-$/);
});
});

View File

@ -1,39 +1,50 @@
import { describe, expect, it } from 'vitest';
import { formatTaskStatusLabel } from '../features/tasks/list/taskStatusLabel.js';
import { formatTaskStatusLabel, formatShortDate } from '../features/tasks/list/taskStatusLabel.js';
import type { TaskListItem } from '../infra/task/types.js';
function makeTask(overrides: Partial<TaskListItem>): TaskListItem {
return {
kind: 'pending',
name: 'test-task',
createdAt: '2026-02-11T00:00:00.000Z',
filePath: '/tmp/task.md',
content: 'content',
...overrides,
};
}
describe('formatTaskStatusLabel', () => {
it("should format pending task as '[pending] name'", () => {
// Given: pending タスク
const task: TaskListItem = {
kind: 'pending',
name: 'implement test',
createdAt: '2026-02-11T00:00:00.000Z',
filePath: '/tmp/task.md',
content: 'content',
};
// When: ステータスラベルを生成する
const result = formatTaskStatusLabel(task);
// Then: pending は pending 表示になる
expect(result).toBe('[pending] implement test');
const task = makeTask({ kind: 'pending', name: 'implement-test' });
expect(formatTaskStatusLabel(task)).toBe('[pending] implement-test');
});
it("should format failed task as '[failed] name'", () => {
// Given: failed タスク
const task: TaskListItem = {
kind: 'failed',
name: 'retry payment',
createdAt: '2026-02-11T00:00:00.000Z',
filePath: '/tmp/task.md',
content: 'content',
};
const task = makeTask({ kind: 'failed', name: 'retry-payment' });
expect(formatTaskStatusLabel(task)).toBe('[failed] retry-payment');
});
// When: ステータスラベルを生成する
const result = formatTaskStatusLabel(task);
it('should include branch when present', () => {
const task = makeTask({
kind: 'completed',
name: 'fix-login-bug',
branch: 'takt/284/fix-login-bug',
});
expect(formatTaskStatusLabel(task)).toBe('[completed] fix-login-bug (takt/284/fix-login-bug)');
});
// Then: failed は failed 表示になる
expect(result).toBe('[failed] retry payment');
it('should not include branch when absent', () => {
const task = makeTask({ kind: 'running', name: 'my-task' });
expect(formatTaskStatusLabel(task)).toBe('[running] my-task');
});
});
describe('formatShortDate', () => {
it('should format ISO string to MM/DD HH:mm', () => {
expect(formatShortDate('2025-02-18T14:30:00.000Z')).toBe('02/18 14:30');
});
it('should zero-pad single digit values', () => {
expect(formatShortDate('2025-01-05T03:07:00.000Z')).toBe('01/05 03:07');
});
});

View File

@ -8,10 +8,11 @@ import * as path from 'node:path';
import * as fs from 'node:fs';
import { promptInput, confirm } from '../../../shared/prompt/index.js';
import { success, info, error, withProgress } from '../../../shared/ui/index.js';
import { TaskRunner, type TaskFileData } from '../../../infra/task/index.js';
import { TaskRunner, type TaskFileData, summarizeTaskName } from '../../../infra/task/index.js';
import { determinePiece } from '../execute/selectAndExecute.js';
import { createLogger, getErrorMessage, generateReportDir } from '../../../shared/utils/index.js';
import { isIssueReference, resolveIssueTask, parseIssueNumbers, createIssue } from '../../../infra/github/index.js';
import { firstLine } from '../../../infra/task/naming.js';
const log = createLogger('add-task');
@ -39,9 +40,11 @@ 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 slug = await summarizeTaskName(taskContent, { cwd });
const summary = firstLine(taskContent);
const taskDirSlug = resolveUniqueTaskSlug(cwd, generateReportDir(taskContent));
const taskDir = path.join(cwd, '.takt', 'tasks', taskDirSlug);
const taskDirRelative = `.takt/tasks/${taskDirSlug}`;
const orderPath = path.join(taskDir, 'order.md');
fs.mkdirSync(taskDir, { recursive: true });
fs.writeFileSync(orderPath, taskContent, 'utf-8');
@ -55,6 +58,8 @@ export async function saveTaskFile(
const created = runner.addTask(taskContent, {
...config,
task_dir: taskDirRelative,
slug,
summary,
});
const tasksFile = path.join(cwd, '.takt', 'tasks.yaml');
log.info('Task created', { taskName: created.name, tasksFile, config });
@ -69,8 +74,8 @@ export async function saveTaskFile(
*/
export function createIssueFromTask(task: string): number | undefined {
info('Creating GitHub Issue...');
const firstLine = task.split('\n')[0] || task;
const title = firstLine.length > 100 ? `${firstLine.slice(0, 97)}...` : firstLine;
const titleLine = task.split('\n')[0] || task;
const title = titleLine.length > 100 ? `${titleLine.slice(0, 97)}...` : titleLine;
const issueResult = createIssue({ title, body: task });
if (issueResult.success) {
success(`Issue created: ${issueResult.url}`);

View File

@ -104,7 +104,7 @@ export async function resolveTaskExecution(
worktreePath = task.worktreePath;
isWorktree = true;
} else {
const taskSlug = await withProgress(
const taskSlug = task.slug ?? await withProgress(
'Generating branch name...',
(slug) => `Branch name generated: ${slug}`,
() => summarizeTaskName(task.content, { cwd: defaultCwd }),

View File

@ -72,7 +72,7 @@ export async function confirmAndCreateWorktree(
}),
);
return { execCwd: result.path, isWorktree: true, branch: result.branch, baseBranch };
return { execCwd: result.path, isWorktree: true, branch: result.branch, baseBranch, taskSlug };
}
/**
@ -92,7 +92,7 @@ export async function selectAndExecuteTask(
return;
}
const { execCwd, isWorktree, branch, baseBranch } = await confirmAndCreateWorktree(
const { execCwd, isWorktree, branch, baseBranch, taskSlug } = await confirmAndCreateWorktree(
cwd,
task,
options?.createWorktree,
@ -112,6 +112,7 @@ export async function selectAndExecuteTask(
...(branch ? { branch } : {}),
...(isWorktree ? { worktree_path: execCwd } : {}),
auto_pr: shouldCreatePr,
...(taskSlug ? { slug: taskSlug } : {}),
});
const startedAt = new Date().toISOString();

View File

@ -122,6 +122,7 @@ export interface WorktreeConfirmationResult {
isWorktree: boolean;
branch?: string;
baseBranch?: string;
taskSlug?: string;
}
export interface SelectAndExecuteOptions {

View File

@ -26,7 +26,7 @@ import {
import { deletePendingTask, deleteFailedTask, deleteCompletedTask } from './taskDeleteActions.js';
import { retryFailedTask } from './taskRetryActions.js';
import { listTasksNonInteractive, type ListNonInteractiveOptions } from './listNonInteractive.js';
import { formatTaskStatusLabel } from './taskStatusLabel.js';
import { formatTaskStatusLabel, formatShortDate } from './taskStatusLabel.js';
export type { ListNonInteractiveOptions } from './listNonInteractive.js';
@ -130,7 +130,7 @@ export async function listTasks(
const menuOptions = tasks.map((task, idx) => ({
label: formatTaskStatusLabel(task),
value: `${task.kind}:${idx}`,
description: `${task.content} | ${task.createdAt}`,
description: `${task.summary ?? task.content} | ${formatShortDate(task.createdAt)}`,
}));
const selected = await selectOption<string>(

View File

@ -18,7 +18,7 @@ import {
mergeBranch,
deleteBranch,
} from './taskActions.js';
import { formatTaskStatusLabel } from './taskStatusLabel.js';
import { formatTaskStatusLabel, formatShortDate } from './taskStatusLabel.js';
export interface ListNonInteractiveOptions {
enabled: boolean;
@ -43,7 +43,7 @@ function printNonInteractiveList(tasks: TaskListItem[], format?: string): void {
}
for (const task of tasks) {
info(`${formatTaskStatusLabel(task)} - ${task.content} (${task.createdAt})`);
info(`${formatTaskStatusLabel(task)} - ${task.summary ?? task.content} (${formatShortDate(task.createdAt)})`);
}
}

View File

@ -8,5 +8,18 @@ const TASK_STATUS_BY_KIND: Record<TaskListItem['kind'], string> = {
};
export function formatTaskStatusLabel(task: TaskListItem): string {
return `[${TASK_STATUS_BY_KIND[task.kind]}] ${task.name}`;
const status = `[${TASK_STATUS_BY_KIND[task.kind]}] ${task.name}`;
if (task.branch) {
return `${status} (${task.branch})`;
}
return status;
}
export function formatShortDate(isoString: string): string {
const date = new Date(isoString);
const month = String(date.getUTCMonth() + 1).padStart(2, '0');
const day = String(date.getUTCDate()).padStart(2, '0');
const hours = String(date.getUTCHours()).padStart(2, '0');
const minutes = String(date.getUTCMinutes()).padStart(2, '0');
return `${month}/${day} ${hours}:${minutes}`;
}

View File

@ -10,7 +10,7 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import { execFileSync } from 'node:child_process';
import { createLogger, slugify } from '../../shared/utils/index.js';
import { createLogger } from '../../shared/utils/index.js';
import { resolveConfigValue } from '../config/index.js';
import type { WorktreeOptions, WorktreeResult } from './types.js';
@ -48,7 +48,7 @@ export class CloneManager {
/** Resolve the clone path based on options and global config */
private static resolveClonePath(projectDir: string, options: WorktreeOptions): string {
const timestamp = CloneManager.generateTimestamp();
const slug = slugify(options.taskSlug);
const slug = options.taskSlug;
let dirName: string;
if (options.issueNumber !== undefined && slug) {
@ -74,7 +74,7 @@ export class CloneManager {
return options.branch;
}
const slug = slugify(options.taskSlug);
const slug = options.taskSlug;
if (options.issueNumber !== undefined && slug) {
return `takt/${options.issueNumber}/${slug}`;

View File

@ -1,12 +1,9 @@
import * as fs from 'node:fs';
import * as path from 'node:path';
import { TaskFileSchema, type TaskFileData, type TaskRecord } from './schema.js';
import { firstLine } from './naming.js';
import type { TaskInfo, TaskListItem } from './types.js';
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('..')) {
@ -66,6 +63,7 @@ export function toTaskInfo(projectDir: string, tasksFile: string, task: TaskReco
return {
filePath: tasksFile,
name: task.name,
slug: task.slug,
content,
taskDir: task.task_dir,
createdAt: task.created_at,
@ -119,6 +117,7 @@ function toBaseTaskListItem(projectDir: string, tasksFile: string, task: TaskRec
createdAt: task.created_at,
filePath: tasksFile,
content: firstLine(resolveTaskContent(projectDir, task)),
summary: task.summary,
branch: task.branch,
worktreePath: task.worktree_path,
startedAt: task.started_at ?? undefined,

View File

@ -5,18 +5,3 @@ export function nowIso(): string {
export function firstLine(content: string): string {
return content.trim().split('\n')[0]?.slice(0, 80) ?? '';
}
export function sanitizeTaskName(base: string): string {
const normalized = base
.toLowerCase()
.replace(/[^a-z0-9\s-]/g, ' ')
.trim()
.replace(/\s+/g, '-')
.replace(/-+/g, '-');
if (!normalized) {
return `task-${Date.now()}`;
}
return normalized;
}

View File

@ -33,7 +33,13 @@ export class TaskRunner {
addTask(
content: string,
options?: Omit<TaskFileData, 'task'> & { content_file?: string; task_dir?: string; worktree_path?: string },
options?: Omit<TaskFileData, 'task'> & {
content_file?: string;
task_dir?: string;
worktree_path?: string;
slug?: string;
summary?: string;
},
): TaskInfo {
return this.lifecycle.addTask(content, options);
}

View File

@ -41,6 +41,8 @@ export type TaskFailure = z.infer<typeof TaskFailureSchema>;
export const TaskRecordSchema = TaskExecutionConfigSchema.extend({
name: z.string().min(1),
status: TaskStatusSchema,
slug: z.string().optional(),
summary: z.string().optional(),
worktree_path: z.string().optional(),
content: z.string().min(1).optional(),
content_file: z.string().min(1).optional(),

View File

@ -7,7 +7,7 @@
import * as wanakana from 'wanakana';
import { resolveConfigValues } from '../config/index.js';
import { getProvider, type ProviderType } from '../providers/index.js';
import { createLogger } from '../../shared/utils/index.js';
import { createLogger, slugify } from '../../shared/utils/index.js';
import { loadTemplate } from '../../shared/prompts/index.js';
import type { SummarizeOptions } from './types.js';
@ -15,27 +15,12 @@ export type { SummarizeOptions };
const log = createLogger('summarize');
/**
* Sanitize a string for use as git branch name and directory name.
* Allows only: a-z, 0-9, hyphen.
*/
function sanitizeSlug(input: string, maxLength = 30): string {
return input
.trim()
.toLowerCase()
.replace(/[^a-z0-9-]/g, '-')
.replace(/-+/g, '-')
.replace(/^-+/, '')
.slice(0, maxLength)
.replace(/-+$/, '');
}
/**
* Convert Japanese text to romaji slug.
*/
function toRomajiSlug(text: string): string {
const romaji = wanakana.toRomaji(text, { customRomajiMapping: {} });
return sanitizeSlug(romaji);
return slugify(romaji);
}
/**
@ -77,7 +62,7 @@ export class TaskSummarizer {
permissionMode: 'readonly',
});
const slug = sanitizeSlug(response.content);
const slug = slugify(response.content);
log.info('Task name summarized', { original: taskName, slug });
return slug || 'task';

View File

@ -3,7 +3,8 @@ import { TaskRecordSchema, type TaskFileData, type TaskRecord, type TaskFailure
import type { TaskInfo, TaskResult } from './types.js';
import { toTaskInfo } from './mapper.js';
import { TaskStore } from './store.js';
import { firstLine, nowIso, sanitizeTaskName } from './naming.js';
import { firstLine, nowIso } from './naming.js';
import { slugify } from '../../shared/utils/slug.js';
import { isStaleRunningTask } from './process.js';
import type { TaskStatus } from './schema.js';
@ -16,13 +17,22 @@ export class TaskLifecycleService {
addTask(
content: string,
options?: Omit<TaskFileData, 'task'> & { content_file?: string; task_dir?: string; worktree_path?: string },
options?: Omit<TaskFileData, 'task'> & {
content_file?: string;
task_dir?: string;
worktree_path?: string;
slug?: string;
summary?: string;
},
): TaskInfo {
const state = this.store.update((current) => {
const name = this.generateTaskName(content, current.tasks.map((task) => task.name));
const slug = options?.slug ?? slugify(firstLine(content));
const name = this.generateTaskName(slug, current.tasks.map((task) => task.name));
const contentValue = options?.task_dir ? undefined : content;
const record: TaskRecord = TaskRecordSchema.parse({
name,
slug,
summary: options?.summary,
status: 'pending',
content: contentValue,
created_at: nowIso(),
@ -258,8 +268,8 @@ export class TaskLifecycleService {
return isStaleRunningTask(task.owner_pid ?? undefined);
}
private generateTaskName(content: string, existingNames: string[]): string {
const base = sanitizeTaskName(firstLine(content));
private generateTaskName(slug: string, existingNames: string[]): string {
const base = slug || `task-${Date.now()}`;
let candidate = base;
let counter = 1;
while (existingNames.includes(candidate)) {

View File

@ -9,6 +9,7 @@ import type { TaskFailure, TaskStatus } from './schema.js';
export interface TaskInfo {
filePath: string;
name: string;
slug?: string;
content: string;
taskDir?: string;
createdAt: string;
@ -81,6 +82,7 @@ export interface TaskListItem {
createdAt: string;
filePath: string;
content: string;
summary?: string;
branch?: string;
worktreePath?: string;
data?: TaskFileData;

View File

@ -2,6 +2,8 @@
* Report directory name generation.
*/
import { slugify } from './slug.js';
export function generateReportDir(task: string): string {
const now = new Date();
const timestamp = now.toISOString()
@ -9,12 +11,7 @@ export function generateReportDir(task: string): string {
.slice(0, 14)
.replace(/(\d{8})(\d{6})/, '$1-$2');
const summary = task
.slice(0, 30)
.toLowerCase()
.replace(/[^a-z0-9\u3040-\u309f\u30a0-\u30ff\u4e00-\u9faf]+/g, '-')
.replace(/^-+|-+$/g, '')
|| 'task';
const summary = slugify(task.slice(0, 80)) || 'task';
return `${timestamp}-${summary}`;
}

View File

@ -2,17 +2,18 @@
* Text slugification utility
*
* Converts text into URL/filename-safe slugs.
* Supports ASCII alphanumerics and CJK characters.
* Allowed characters: a-z, 0-9, hyphen. Max 30 characters.
*/
/**
* Convert text into a slug for use in filenames, paths, and branch names.
* Preserves CJK characters (U+3000-9FFF, FF00-FFEF).
* Allowed: a-z 0-9 hyphen. Max 30 characters.
*/
export function slugify(text: string): string {
return text
.toLowerCase()
.replace(/[^a-z0-9\u3000-\u9fff\uff00-\uffef]+/g, '-')
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 50);
.slice(0, 30)
.replace(/-+$/, '');
}

View File

@ -1,5 +1,5 @@
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])?';
'[a-z0-9](?:[a-z0-9-]*[a-z0-9])?';
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}$`);