diff --git a/src/__tests__/cli-worktree.test.ts b/src/__tests__/cli-worktree.test.ts index 5f8477a..68e7959 100644 --- a/src/__tests__/cli-worktree.test.ts +++ b/src/__tests__/cli-worktree.test.ts @@ -18,6 +18,10 @@ vi.mock('../task/autoCommit.js', () => ({ autoCommitWorktree: vi.fn(), })); +vi.mock('../task/summarize.js', () => ({ + summarizeTaskName: vi.fn(), +})); + vi.mock('../utils/ui.js', () => ({ info: vi.fn(), error: vi.fn(), @@ -73,11 +77,13 @@ vi.mock('../constants.js', () => ({ import { confirm } from '../prompt/index.js'; import { createWorktree } from '../task/worktree.js'; +import { summarizeTaskName } from '../task/summarize.js'; import { info } from '../utils/ui.js'; import { confirmAndCreateWorktree } from '../cli.js'; const mockConfirm = vi.mocked(confirm); const mockCreateWorktree = vi.mocked(createWorktree); +const mockSummarizeTaskName = vi.mocked(summarizeTaskName); const mockInfo = vi.mocked(info); beforeEach(() => { @@ -96,11 +102,13 @@ describe('confirmAndCreateWorktree', () => { expect(result.execCwd).toBe('/project'); expect(result.isWorktree).toBe(false); expect(mockCreateWorktree).not.toHaveBeenCalled(); + expect(mockSummarizeTaskName).not.toHaveBeenCalled(); }); it('should create worktree and return worktree path when user confirms', async () => { // Given: user says "yes" to worktree creation mockConfirm.mockResolvedValue(true); + mockSummarizeTaskName.mockResolvedValue('fix-auth'); mockCreateWorktree.mockReturnValue({ path: '/project/.takt/worktrees/20260128T0504-fix-auth', branch: 'takt/20260128T0504-fix-auth', @@ -112,6 +120,7 @@ describe('confirmAndCreateWorktree', () => { // Then expect(result.execCwd).toBe('/project/.takt/worktrees/20260128T0504-fix-auth'); expect(result.isWorktree).toBe(true); + expect(mockSummarizeTaskName).toHaveBeenCalledWith('fix-auth', { cwd: '/project' }); expect(mockCreateWorktree).toHaveBeenCalledWith('/project', { worktree: true, taskSlug: 'fix-auth', @@ -121,6 +130,7 @@ describe('confirmAndCreateWorktree', () => { it('should display worktree info when created', async () => { // Given mockConfirm.mockResolvedValue(true); + mockSummarizeTaskName.mockResolvedValue('my-task'); mockCreateWorktree.mockReturnValue({ path: '/project/.takt/worktrees/20260128T0504-my-task', branch: 'takt/20260128T0504-my-task', @@ -146,21 +156,39 @@ describe('confirmAndCreateWorktree', () => { expect(mockConfirm).toHaveBeenCalledWith('Create worktree?', false); }); - it('should pass task as taskSlug to createWorktree', async () => { - // Given: Japanese task name + it('should summarize Japanese task name to English slug', async () => { + // Given: Japanese task name, AI summarizes to English mockConfirm.mockResolvedValue(true); + mockSummarizeTaskName.mockResolvedValue('add-auth'); mockCreateWorktree.mockReturnValue({ - path: '/project/.takt/worktrees/20260128T0504-task', - branch: 'takt/20260128T0504-task', + path: '/project/.takt/worktrees/20260128T0504-add-auth', + branch: 'takt/20260128T0504-add-auth', }); // When await confirmAndCreateWorktree('/project', '認証機能を追加する'); // Then + expect(mockSummarizeTaskName).toHaveBeenCalledWith('認証機能を追加する', { cwd: '/project' }); expect(mockCreateWorktree).toHaveBeenCalledWith('/project', { worktree: true, - taskSlug: '認証機能を追加する', + taskSlug: 'add-auth', }); }); + + it('should show generating message when creating worktree', async () => { + // Given + mockConfirm.mockResolvedValue(true); + mockSummarizeTaskName.mockResolvedValue('test-task'); + mockCreateWorktree.mockReturnValue({ + path: '/project/.takt/worktrees/20260128T0504-test-task', + branch: 'takt/20260128T0504-test-task', + }); + + // When + await confirmAndCreateWorktree('/project', 'テストタスク'); + + // Then + expect(mockInfo).toHaveBeenCalledWith('Generating branch name...'); + }); }); diff --git a/src/__tests__/summarize.test.ts b/src/__tests__/summarize.test.ts new file mode 100644 index 0000000..d350be2 --- /dev/null +++ b/src/__tests__/summarize.test.ts @@ -0,0 +1,173 @@ +/** + * Tests for summarizeTaskName + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('../claude/client.js', () => ({ + callClaude: vi.fn(), +})); + +vi.mock('../utils/debug.js', () => ({ + createLogger: () => ({ + info: vi.fn(), + debug: vi.fn(), + error: vi.fn(), + }), +})); + +import { callClaude } from '../claude/client.js'; +import { summarizeTaskName } from '../task/summarize.js'; + +const mockCallClaude = vi.mocked(callClaude); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('summarizeTaskName', () => { + it('should return AI-generated slug for Japanese task name', async () => { + // Given: AI returns a slug for Japanese input + mockCallClaude.mockResolvedValue({ + agent: 'summarizer', + status: 'done', + content: 'add-auth', + timestamp: new Date(), + }); + + // When + const result = await summarizeTaskName('認証機能を追加する', { cwd: '/project' }); + + // Then + expect(result).toBe('add-auth'); + expect(mockCallClaude).toHaveBeenCalledWith( + 'summarizer', + 'Summarize this task: "認証機能を追加する"', + expect.objectContaining({ + cwd: '/project', + model: 'haiku', + maxTurns: 1, + allowedTools: [], + }) + ); + }); + + it('should return AI-generated slug for English task name', async () => { + // Given + mockCallClaude.mockResolvedValue({ + agent: 'summarizer', + status: 'done', + content: 'fix-login-bug', + timestamp: new Date(), + }); + + // When + const result = await summarizeTaskName('Fix the login bug', { cwd: '/project' }); + + // Then + expect(result).toBe('fix-login-bug'); + }); + + it('should clean up AI response with extra characters', async () => { + // Given: AI response has extra whitespace or formatting + mockCallClaude.mockResolvedValue({ + agent: 'summarizer', + status: 'done', + content: ' Add-User-Auth! \n', + timestamp: new Date(), + }); + + // When + const result = await summarizeTaskName('ユーザー認証を追加', { cwd: '/project' }); + + // Then + expect(result).toBe('add-user-auth'); + }); + + it('should truncate long slugs to 30 characters', async () => { + // Given: AI returns a long slug + mockCallClaude.mockResolvedValue({ + agent: 'summarizer', + status: 'done', + content: 'this-is-a-very-long-slug-that-exceeds-thirty-characters', + timestamp: new Date(), + }); + + // When + const result = await summarizeTaskName('長いタスク名', { cwd: '/project' }); + + // Then + expect(result.length).toBeLessThanOrEqual(30); + expect(result).toBe('this-is-a-very-long-slug-that-'); + }); + + it('should return "task" as fallback for empty AI response', async () => { + // Given: AI returns empty string + mockCallClaude.mockResolvedValue({ + agent: 'summarizer', + status: 'done', + content: '', + timestamp: new Date(), + }); + + // When + const result = await summarizeTaskName('test', { cwd: '/project' }); + + // Then + expect(result).toBe('task'); + }); + + it('should use custom model if specified', async () => { + // Given + mockCallClaude.mockResolvedValue({ + agent: 'summarizer', + status: 'done', + content: 'custom-task', + timestamp: new Date(), + }); + + // When + await summarizeTaskName('test', { cwd: '/project', model: 'sonnet' }); + + // Then + expect(mockCallClaude).toHaveBeenCalledWith( + 'summarizer', + expect.any(String), + expect.objectContaining({ + model: 'sonnet', + }) + ); + }); + + it('should remove consecutive hyphens', async () => { + // Given: AI response has consecutive hyphens + mockCallClaude.mockResolvedValue({ + agent: 'summarizer', + status: 'done', + content: 'fix---multiple---hyphens', + timestamp: new Date(), + }); + + // When + const result = await summarizeTaskName('test', { cwd: '/project' }); + + // Then + expect(result).toBe('fix-multiple-hyphens'); + }); + + it('should remove leading and trailing hyphens', async () => { + // Given: AI response has leading/trailing hyphens + mockCallClaude.mockResolvedValue({ + agent: 'summarizer', + status: 'done', + content: '-leading-trailing-', + timestamp: new Date(), + }); + + // When + const result = await summarizeTaskName('test', { cwd: '/project' }); + + // Then + expect(result).toBe('leading-trailing'); + }); +}); diff --git a/src/cli.ts b/src/cli.ts index 01c3c4d..f9a8b80 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -38,6 +38,7 @@ import { listWorkflows } from './config/workflowLoader.js'; import { selectOptionWithDefault, confirm } from './prompt/index.js'; import { createWorktree } from './task/worktree.js'; import { autoCommitWorktree } from './task/autoCommit.js'; +import { summarizeTaskName } from './task/summarize.js'; import { DEFAULT_WORKFLOW_NAME } from './constants.js'; const log = createLogger('cli'); @@ -50,6 +51,7 @@ export interface WorktreeConfirmationResult { /** * Ask user whether to create a worktree, and create one if confirmed. * Returns the execution directory and whether a worktree was created. + * Task name is summarized to English by AI for use in branch/worktree names. */ export async function confirmAndCreateWorktree( cwd: string, @@ -61,9 +63,13 @@ export async function confirmAndCreateWorktree( return { execCwd: cwd, isWorktree: false }; } + // Summarize task name to English slug using AI + info('Generating branch name...'); + const taskSlug = await summarizeTaskName(task, { cwd }); + const result = createWorktree(cwd, { worktree: true, - taskSlug: task, + taskSlug, }); info(`Worktree created: ${result.path} (branch: ${result.branch})`); diff --git a/src/task/summarize.ts b/src/task/summarize.ts new file mode 100644 index 0000000..c6a5796 --- /dev/null +++ b/src/task/summarize.ts @@ -0,0 +1,67 @@ +/** + * Task name summarization using AI + * + * Generates concise English summaries for use in branch names and worktree paths. + */ + +import { callClaude } from '../claude/client.js'; +import { createLogger } from '../utils/debug.js'; + +const log = createLogger('summarize'); + +const SUMMARIZE_SYSTEM_PROMPT = `You are a helpful assistant that generates concise English slugs for git branch names. + +Rules: +- Output ONLY the slug, nothing else +- Use lowercase letters, numbers, and hyphens only +- Maximum 30 characters +- No leading/trailing hyphens +- Be descriptive but concise +- If the input is already in English and short, simplify it + +Examples: +- "認証機能を追加する" → "add-auth" +- "Fix the login bug" → "fix-login-bug" +- "worktreeを作るときブランチ名をAIで生成" → "ai-branch-naming" +- "Add user registration with email verification" → "add-user-registration"`; + +export interface SummarizeOptions { + /** Working directory for Claude execution */ + cwd: string; + /** Model to use (optional, defaults to haiku for speed) */ + model?: string; +} + +/** + * Summarize a task name into a concise English slug using AI. + * + * @param taskName - Original task name (can be in any language) + * @param options - Summarization options + * @returns English slug suitable for branch names + */ +export async function summarizeTaskName( + taskName: string, + options: SummarizeOptions +): Promise { + log.info('Summarizing task name', { taskName }); + + const response = await callClaude('summarizer', `Summarize this task: "${taskName}"`, { + cwd: options.cwd, + model: options.model ?? 'haiku', + maxTurns: 1, + systemPrompt: SUMMARIZE_SYSTEM_PROMPT, + allowedTools: [], + }); + + const slug = response.content + .trim() + .toLowerCase() + .replace(/[^a-z0-9-]/g, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, '') + .slice(0, 30); + + log.info('Task name summarized', { original: taskName, slug }); + + return slug || 'task'; +}