タスク名を AI で英語スラグに要約する機能を追加

worktree 作成時、日本語タスク名を英語ブランチ名に変換。
- summarizeTaskName() を追加(Claude で要約)
- 例: 「認証機能を追加する」→ add-auth
This commit is contained in:
nrslib 2026-01-29 01:23:24 +09:00
parent 9eb63e787e
commit 82193e6db4
4 changed files with 280 additions and 6 deletions

View File

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

View File

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

View File

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

67
src/task/summarize.ts Normal file
View File

@ -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<string> {
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';
}