diff --git a/src/__tests__/naming.test.ts b/src/__tests__/naming.test.ts index d6bedbf..7cad07b 100644 --- a/src/__tests__/naming.test.ts +++ b/src/__tests__/naming.test.ts @@ -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'); - }); -}); diff --git a/src/__tests__/reportDir.test.ts b/src/__tests__/reportDir.test.ts index 1163eaf..59013fc 100644 --- a/src/__tests__/reportDir.test.ts +++ b/src/__tests__/reportDir.test.ts @@ -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(); }); diff --git a/src/__tests__/saveTaskFile.test.ts b/src/__tests__/saveTaskFile.test.ts index 9da02c7..f4d6459 100644 --- a/src/__tests__/saveTaskFile.test.ts +++ b/src/__tests__/saveTaskFile.test.ts @@ -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'); diff --git a/src/__tests__/slug.test.ts b/src/__tests__/slug.test.ts index fd9ef78..8538809 100644 --- a/src/__tests__/slug.test.ts +++ b/src/__tests__/slug.test.ts @@ -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(/-$/); + }); }); diff --git a/src/__tests__/taskStatusLabel.test.ts b/src/__tests__/taskStatusLabel.test.ts index 7efb53f..8e06987 100644 --- a/src/__tests__/taskStatusLabel.test.ts +++ b/src/__tests__/taskStatusLabel.test.ts @@ -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 { + 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'); }); }); diff --git a/src/features/tasks/add/index.ts b/src/features/tasks/add/index.ts index 79599f7..107e2de 100644 --- a/src/features/tasks/add/index.ts +++ b/src/features/tasks/add/index.ts @@ -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}`); diff --git a/src/features/tasks/execute/resolveTask.ts b/src/features/tasks/execute/resolveTask.ts index 4367a6f..60adb6d 100644 --- a/src/features/tasks/execute/resolveTask.ts +++ b/src/features/tasks/execute/resolveTask.ts @@ -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 }), diff --git a/src/features/tasks/execute/selectAndExecute.ts b/src/features/tasks/execute/selectAndExecute.ts index 0dfa260..dccfb31 100644 --- a/src/features/tasks/execute/selectAndExecute.ts +++ b/src/features/tasks/execute/selectAndExecute.ts @@ -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(); diff --git a/src/features/tasks/execute/types.ts b/src/features/tasks/execute/types.ts index 30f07de..2636115 100644 --- a/src/features/tasks/execute/types.ts +++ b/src/features/tasks/execute/types.ts @@ -122,6 +122,7 @@ export interface WorktreeConfirmationResult { isWorktree: boolean; branch?: string; baseBranch?: string; + taskSlug?: string; } export interface SelectAndExecuteOptions { diff --git a/src/features/tasks/list/index.ts b/src/features/tasks/list/index.ts index 351cbe3..7367161 100644 --- a/src/features/tasks/list/index.ts +++ b/src/features/tasks/list/index.ts @@ -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( diff --git a/src/features/tasks/list/listNonInteractive.ts b/src/features/tasks/list/listNonInteractive.ts index 3c11cad..f8c271b 100644 --- a/src/features/tasks/list/listNonInteractive.ts +++ b/src/features/tasks/list/listNonInteractive.ts @@ -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)})`); } } diff --git a/src/features/tasks/list/taskStatusLabel.ts b/src/features/tasks/list/taskStatusLabel.ts index 4a891b1..2212784 100644 --- a/src/features/tasks/list/taskStatusLabel.ts +++ b/src/features/tasks/list/taskStatusLabel.ts @@ -8,5 +8,18 @@ const TASK_STATUS_BY_KIND: Record = { }; 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}`; } diff --git a/src/infra/task/clone.ts b/src/infra/task/clone.ts index a8cd649..ad4bc22 100644 --- a/src/infra/task/clone.ts +++ b/src/infra/task/clone.ts @@ -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}`; diff --git a/src/infra/task/mapper.ts b/src/infra/task/mapper.ts index c424b75..ae561a4 100644 --- a/src/infra/task/mapper.ts +++ b/src/infra/task/mapper.ts @@ -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, diff --git a/src/infra/task/naming.ts b/src/infra/task/naming.ts index 649fe8e..f208b48 100644 --- a/src/infra/task/naming.ts +++ b/src/infra/task/naming.ts @@ -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; -} diff --git a/src/infra/task/runner.ts b/src/infra/task/runner.ts index 20c658c..b8266d1 100644 --- a/src/infra/task/runner.ts +++ b/src/infra/task/runner.ts @@ -33,7 +33,13 @@ export class TaskRunner { addTask( content: string, - options?: Omit & { content_file?: string; task_dir?: string; worktree_path?: string }, + options?: Omit & { + content_file?: string; + task_dir?: string; + worktree_path?: string; + slug?: string; + summary?: string; + }, ): TaskInfo { return this.lifecycle.addTask(content, options); } diff --git a/src/infra/task/schema.ts b/src/infra/task/schema.ts index 3f5cc52..7f9c607 100644 --- a/src/infra/task/schema.ts +++ b/src/infra/task/schema.ts @@ -41,6 +41,8 @@ export type TaskFailure = z.infer; 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(), diff --git a/src/infra/task/summarize.ts b/src/infra/task/summarize.ts index 4a6913d..662b2fb 100644 --- a/src/infra/task/summarize.ts +++ b/src/infra/task/summarize.ts @@ -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'; diff --git a/src/infra/task/taskLifecycleService.ts b/src/infra/task/taskLifecycleService.ts index 6cf6a93..c236103 100644 --- a/src/infra/task/taskLifecycleService.ts +++ b/src/infra/task/taskLifecycleService.ts @@ -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 & { content_file?: string; task_dir?: string; worktree_path?: string }, + options?: Omit & { + 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)) { diff --git a/src/infra/task/types.ts b/src/infra/task/types.ts index aee43fa..42d0f57 100644 --- a/src/infra/task/types.ts +++ b/src/infra/task/types.ts @@ -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; diff --git a/src/shared/utils/reportDir.ts b/src/shared/utils/reportDir.ts index 84480ac..244133f 100644 --- a/src/shared/utils/reportDir.ts +++ b/src/shared/utils/reportDir.ts @@ -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}`; } diff --git a/src/shared/utils/slug.ts b/src/shared/utils/slug.ts index 6bf6440..8dc0e7f 100644 --- a/src/shared/utils/slug.ts +++ b/src/shared/utils/slug.ts @@ -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(/-+$/, ''); } diff --git a/src/shared/utils/taskPaths.ts b/src/shared/utils/taskPaths.ts index b12d582..905eb4c 100644 --- a/src/shared/utils/taskPaths.ts +++ b/src/shared/utils/taskPaths.ts @@ -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}$`);