improve task name summarization for branch/worktree names

- Improve prompt to prevent "this task is..." style output
- Add LLM/romanization option (useLLM: false for romaji fallback)
- Add sanitizeSlug function for safe branch/directory names
- Add wanakana library for Japanese to romaji conversion
This commit is contained in:
nrslib 2026-01-29 09:28:51 +09:00
parent 1f636bff23
commit f83b826a3d
8 changed files with 690 additions and 60 deletions

21
package-lock.json generated
View File

@ -13,16 +13,19 @@
"@openai/codex-sdk": "^0.91.0",
"chalk": "^5.3.0",
"commander": "^12.1.0",
"wanakana": "^5.3.1",
"yaml": "^2.4.5",
"zod": "^4.3.6"
},
"bin": {
"takt": "bin/takt",
"takt-cli": "dist/cli.js"
"takt-cli": "dist/cli.js",
"takt-dev": "bin/takt"
},
"devDependencies": {
"@eslint/js": "^9.39.2",
"@types/node": "^20.14.0",
"@types/wanakana": "^4.0.6",
"@typescript-eslint/eslint-plugin": "^8.0.0",
"@typescript-eslint/parser": "^8.0.0",
"eslint": "^9.0.0",
@ -1363,6 +1366,13 @@
"undici-types": "~6.21.0"
}
},
"node_modules/@types/wanakana": {
"version": "4.0.6",
"resolved": "https://registry.npmjs.org/@types/wanakana/-/wanakana-4.0.6.tgz",
"integrity": "sha512-al8hJELQI+RDcexy6JLV/BqghQ/nP0B9d62m0F3jEvPyxAq9RXFH9xDoGa73oT9/keCUKRxWCA6l37wv4TCfQw==",
"dev": true,
"license": "MIT"
},
"node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.53.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.53.1.tgz",
@ -3217,6 +3227,15 @@
}
}
},
"node_modules/wanakana": {
"version": "5.3.1",
"resolved": "https://registry.npmjs.org/wanakana/-/wanakana-5.3.1.tgz",
"integrity": "sha512-OSDqupzTlzl2LGyqTdhcXcl6ezMiFhcUwLBP8YKaBIbMYW1wAwDvupw2T9G9oVaKT9RmaSpyTXjxddFPUcFFIw==",
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",

View File

@ -54,12 +54,14 @@
"@openai/codex-sdk": "^0.91.0",
"chalk": "^5.3.0",
"commander": "^12.1.0",
"wanakana": "^5.3.1",
"yaml": "^2.4.5",
"zod": "^4.3.6"
},
"devDependencies": {
"@eslint/js": "^9.39.2",
"@types/node": "^20.14.0",
"@types/wanakana": "^4.0.6",
"@typescript-eslint/eslint-plugin": "^8.0.0",
"@typescript-eslint/parser": "^8.0.0",
"eslint": "^9.0.0",

View File

@ -0,0 +1,193 @@
/**
* Tests for addTask command
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import { tmpdir } from 'node:os';
// Mock dependencies before importing the module under test
vi.mock('../prompt/index.js', () => ({
promptInput: vi.fn(),
promptMultilineInput: vi.fn(),
confirm: vi.fn(),
selectOption: vi.fn(),
}));
vi.mock('../task/summarize.js', () => ({
summarizeTaskName: vi.fn(),
}));
vi.mock('../utils/ui.js', () => ({
success: vi.fn(),
info: vi.fn(),
}));
vi.mock('../utils/debug.js', () => ({
createLogger: () => ({
info: vi.fn(),
debug: vi.fn(),
error: vi.fn(),
}),
}));
vi.mock('../config/workflowLoader.js', () => ({
listWorkflows: vi.fn(),
}));
vi.mock('../config/paths.js', () => ({
getCurrentWorkflow: vi.fn(() => 'default'),
}));
import { promptMultilineInput, confirm, selectOption } from '../prompt/index.js';
import { summarizeTaskName } from '../task/summarize.js';
import { listWorkflows } from '../config/workflowLoader.js';
import { addTask } from '../commands/addTask.js';
const mockPromptMultilineInput = vi.mocked(promptMultilineInput);
const mockConfirm = vi.mocked(confirm);
const mockSelectOption = vi.mocked(selectOption);
const mockSummarizeTaskName = vi.mocked(summarizeTaskName);
const mockListWorkflows = vi.mocked(listWorkflows);
let testDir: string;
beforeEach(() => {
vi.clearAllMocks();
// Create temporary test directory
testDir = fs.mkdtempSync(path.join(tmpdir(), 'takt-test-'));
// Default mock setup
mockListWorkflows.mockReturnValue([]);
mockConfirm.mockResolvedValue(false);
});
afterEach(() => {
// Cleanup test directory
if (testDir && fs.existsSync(testDir)) {
fs.rmSync(testDir, { recursive: true });
}
});
describe('addTask', () => {
it('should create task file with AI-generated slug for argument mode', async () => {
// Given: Task content provided as argument
mockSummarizeTaskName.mockResolvedValue('add-auth');
mockConfirm.mockResolvedValue(false);
// When
await addTask(testDir, ['認証機能を追加する']);
// Then
const tasksDir = path.join(testDir, '.takt', 'tasks');
const taskFile = path.join(tasksDir, 'add-auth.yaml');
expect(fs.existsSync(taskFile)).toBe(true);
const content = fs.readFileSync(taskFile, 'utf-8');
expect(content).toContain('task: 認証機能を追加する');
});
it('should use AI-summarized slug for Japanese task content', async () => {
// Given: Japanese task
mockSummarizeTaskName.mockResolvedValue('fix-login-bug');
mockConfirm.mockResolvedValue(false);
// When
await addTask(testDir, ['ログインバグを修正する']);
// Then
expect(mockSummarizeTaskName).toHaveBeenCalledWith('ログインバグを修正する', { cwd: testDir });
const taskFile = path.join(testDir, '.takt', 'tasks', 'fix-login-bug.yaml');
expect(fs.existsSync(taskFile)).toBe(true);
});
it('should handle multiline task content using first line for filename', async () => {
// Given: Multiline task content in interactive mode
mockPromptMultilineInput.mockResolvedValue('First line task\nSecond line details');
mockSummarizeTaskName.mockResolvedValue('first-line-task');
mockConfirm.mockResolvedValue(false);
// When
await addTask(testDir, []);
// Then
expect(mockSummarizeTaskName).toHaveBeenCalledWith('First line task', { cwd: testDir });
});
it('should use fallback filename when AI returns empty', async () => {
// Given: AI returns empty slug (which defaults to 'task' in summarizeTaskName)
mockSummarizeTaskName.mockResolvedValue('task');
mockConfirm.mockResolvedValue(false);
// When
await addTask(testDir, ['test']);
// Then
const taskFile = path.join(testDir, '.takt', 'tasks', 'task.yaml');
expect(fs.existsSync(taskFile)).toBe(true);
});
it('should append counter for duplicate filenames', async () => {
// Given: First task creates 'my-task.yaml'
mockSummarizeTaskName.mockResolvedValue('my-task');
mockConfirm.mockResolvedValue(false);
// When: Create first task
await addTask(testDir, ['First task']);
// And: Create second task with same slug
await addTask(testDir, ['Second task']);
// Then: Second file should have counter
const tasksDir = path.join(testDir, '.takt', 'tasks');
expect(fs.existsSync(path.join(tasksDir, 'my-task.yaml'))).toBe(true);
expect(fs.existsSync(path.join(tasksDir, 'my-task-1.yaml'))).toBe(true);
});
it('should include worktree option in task file when confirmed', async () => {
// Given: User confirms worktree creation
mockSummarizeTaskName.mockResolvedValue('with-worktree');
mockConfirm.mockResolvedValue(true);
// When
await addTask(testDir, ['Task with worktree']);
// Then
const taskFile = path.join(testDir, '.takt', 'tasks', 'with-worktree.yaml');
const content = fs.readFileSync(taskFile, 'utf-8');
expect(content).toContain('worktree: true');
});
it('should cancel when interactive mode returns null', async () => {
// Given: User cancels multiline input
mockPromptMultilineInput.mockResolvedValue(null);
// When
await addTask(testDir, []);
// Then
const tasksDir = path.join(testDir, '.takt', 'tasks');
const files = fs.existsSync(tasksDir) ? fs.readdirSync(tasksDir) : [];
expect(files.length).toBe(0);
expect(mockSummarizeTaskName).not.toHaveBeenCalled();
});
it('should include workflow selection in task file', async () => {
// Given: Multiple workflows available
mockListWorkflows.mockReturnValue(['default', 'review']);
mockSummarizeTaskName.mockResolvedValue('with-workflow');
mockConfirm.mockResolvedValue(false);
mockSelectOption.mockResolvedValue('review');
// When
await addTask(testDir, ['Task with workflow']);
// Then
const taskFile = path.join(testDir, '.takt', 'tasks', 'with-workflow.yaml');
const content = fs.readFileSync(taskFile, 'utf-8');
expect(content).toContain('workflow: review');
});
});

View File

@ -4,8 +4,12 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('../claude/client.js', () => ({
callClaude: vi.fn(),
vi.mock('../providers/index.js', () => ({
getProvider: vi.fn(),
}));
vi.mock('../config/globalConfig.js', () => ({
loadGlobalConfig: vi.fn(),
}));
vi.mock('../utils/debug.js', () => ({
@ -16,19 +20,36 @@ vi.mock('../utils/debug.js', () => ({
}),
}));
import { callClaude } from '../claude/client.js';
import { getProvider } from '../providers/index.js';
import { loadGlobalConfig } from '../config/globalConfig.js';
import { summarizeTaskName } from '../task/summarize.js';
const mockCallClaude = vi.mocked(callClaude);
const mockGetProvider = vi.mocked(getProvider);
const mockLoadGlobalConfig = vi.mocked(loadGlobalConfig);
const mockProviderCall = vi.fn();
const mockProvider = {
call: mockProviderCall,
callCustom: vi.fn(),
};
beforeEach(() => {
vi.clearAllMocks();
mockGetProvider.mockReturnValue(mockProvider);
mockLoadGlobalConfig.mockReturnValue({
language: 'ja',
trustedDirectories: [],
defaultWorkflow: 'default',
logLevel: 'info',
provider: 'claude',
model: 'haiku',
});
});
describe('summarizeTaskName', () => {
it('should return AI-generated slug for Japanese task name', async () => {
// Given: AI returns a slug for Japanese input
mockCallClaude.mockResolvedValue({
mockProviderCall.mockResolvedValue({
agent: 'summarizer',
status: 'done',
content: 'add-auth',
@ -40,13 +61,13 @@ describe('summarizeTaskName', () => {
// Then
expect(result).toBe('add-auth');
expect(mockCallClaude).toHaveBeenCalledWith(
expect(mockGetProvider).toHaveBeenCalledWith('claude');
expect(mockProviderCall).toHaveBeenCalledWith(
'summarizer',
'Summarize this task: "認証機能を追加する"',
'認証機能を追加する',
expect.objectContaining({
cwd: '/project',
model: 'haiku',
maxTurns: 1,
allowedTools: [],
})
);
@ -54,7 +75,7 @@ describe('summarizeTaskName', () => {
it('should return AI-generated slug for English task name', async () => {
// Given
mockCallClaude.mockResolvedValue({
mockProviderCall.mockResolvedValue({
agent: 'summarizer',
status: 'done',
content: 'fix-login-bug',
@ -70,7 +91,7 @@ describe('summarizeTaskName', () => {
it('should clean up AI response with extra characters', async () => {
// Given: AI response has extra whitespace or formatting
mockCallClaude.mockResolvedValue({
mockProviderCall.mockResolvedValue({
agent: 'summarizer',
status: 'done',
content: ' Add-User-Auth! \n',
@ -84,9 +105,9 @@ describe('summarizeTaskName', () => {
expect(result).toBe('add-user-auth');
});
it('should truncate long slugs to 30 characters', async () => {
it('should truncate long slugs to 30 characters without trailing hyphen', async () => {
// Given: AI returns a long slug
mockCallClaude.mockResolvedValue({
mockProviderCall.mockResolvedValue({
agent: 'summarizer',
status: 'done',
content: 'this-is-a-very-long-slug-that-exceeds-thirty-characters',
@ -98,12 +119,13 @@ describe('summarizeTaskName', () => {
// Then
expect(result.length).toBeLessThanOrEqual(30);
expect(result).toBe('this-is-a-very-long-slug-that-');
expect(result).toBe('this-is-a-very-long-slug-that');
expect(result).not.toMatch(/-$/); // No trailing hyphen
});
it('should return "task" as fallback for empty AI response', async () => {
// Given: AI returns empty string
mockCallClaude.mockResolvedValue({
mockProviderCall.mockResolvedValue({
agent: 'summarizer',
status: 'done',
content: '',
@ -117,9 +139,9 @@ describe('summarizeTaskName', () => {
expect(result).toBe('task');
});
it('should use custom model if specified', async () => {
it('should use custom model if specified in options', async () => {
// Given
mockCallClaude.mockResolvedValue({
mockProviderCall.mockResolvedValue({
agent: 'summarizer',
status: 'done',
content: 'custom-task',
@ -130,7 +152,7 @@ describe('summarizeTaskName', () => {
await summarizeTaskName('test', { cwd: '/project', model: 'sonnet' });
// Then
expect(mockCallClaude).toHaveBeenCalledWith(
expect(mockProviderCall).toHaveBeenCalledWith(
'summarizer',
expect.any(String),
expect.objectContaining({
@ -139,9 +161,40 @@ describe('summarizeTaskName', () => {
);
});
it('should use provider from config.yaml', async () => {
// Given: config has codex provider
mockLoadGlobalConfig.mockReturnValue({
language: 'ja',
trustedDirectories: [],
defaultWorkflow: 'default',
logLevel: 'info',
provider: 'codex',
model: 'gpt-4',
});
mockProviderCall.mockResolvedValue({
agent: 'summarizer',
status: 'done',
content: 'codex-task',
timestamp: new Date(),
});
// When
await summarizeTaskName('test', { cwd: '/project' });
// Then
expect(mockGetProvider).toHaveBeenCalledWith('codex');
expect(mockProviderCall).toHaveBeenCalledWith(
'summarizer',
expect.any(String),
expect.objectContaining({
model: 'gpt-4',
})
);
});
it('should remove consecutive hyphens', async () => {
// Given: AI response has consecutive hyphens
mockCallClaude.mockResolvedValue({
mockProviderCall.mockResolvedValue({
agent: 'summarizer',
status: 'done',
content: 'fix---multiple---hyphens',
@ -157,7 +210,7 @@ describe('summarizeTaskName', () => {
it('should remove leading and trailing hyphens', async () => {
// Given: AI response has leading/trailing hyphens
mockCallClaude.mockResolvedValue({
mockProviderCall.mockResolvedValue({
agent: 'summarizer',
status: 'done',
content: '-leading-trailing-',
@ -170,4 +223,49 @@ describe('summarizeTaskName', () => {
// Then
expect(result).toBe('leading-trailing');
});
it('should throw error when config load fails', async () => {
// Given: config loading throws error
mockLoadGlobalConfig.mockImplementation(() => {
throw new Error('Config not found');
});
// When/Then
await expect(summarizeTaskName('test', { cwd: '/project' })).rejects.toThrow('Config not found');
});
it('should use romanization when useLLM is false', async () => {
// When: useLLM is explicitly false
const result = await summarizeTaskName('認証機能を追加する', { cwd: '/project', useLLM: false });
// Then: should not call provider, should return romaji
expect(mockProviderCall).not.toHaveBeenCalled();
expect(result).toMatch(/^[a-z0-9-]+$/);
expect(result.length).toBeLessThanOrEqual(30);
});
it('should handle mixed Japanese/English with romanization', async () => {
// When
const result = await summarizeTaskName('Add 認証機能', { cwd: '/project', useLLM: false });
// Then
expect(result).toMatch(/^[a-z0-9-]+$/);
expect(result).not.toMatch(/^-|-$/); // No leading/trailing hyphens
});
it('should use LLM by default', async () => {
// Given
mockProviderCall.mockResolvedValue({
agent: 'summarizer',
status: 'done',
content: 'add-auth',
timestamp: new Date(),
});
// When: useLLM not specified (defaults to true)
await summarizeTaskName('test', { cwd: '/project' });
// Then: should call provider
expect(mockProviderCall).toHaveBeenCalled();
});
});

View File

@ -0,0 +1,273 @@
/**
* Tests for resolveTaskExecution
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock dependencies before importing the module under test
vi.mock('../config/index.js', () => ({
loadWorkflow: vi.fn(),
loadGlobalConfig: vi.fn(() => ({})),
}));
vi.mock('../task/index.js', () => ({
TaskRunner: vi.fn(),
}));
vi.mock('../task/worktree.js', () => ({
createWorktree: vi.fn(),
}));
vi.mock('../task/autoCommit.js', () => ({
autoCommitWorktree: vi.fn(),
}));
vi.mock('../task/summarize.js', () => ({
summarizeTaskName: vi.fn(),
}));
vi.mock('../utils/ui.js', () => ({
header: vi.fn(),
info: vi.fn(),
error: vi.fn(),
success: vi.fn(),
status: vi.fn(),
}));
vi.mock('../utils/debug.js', () => ({
createLogger: () => ({
info: vi.fn(),
debug: vi.fn(),
error: vi.fn(),
}),
}));
vi.mock('../utils/error.js', () => ({
getErrorMessage: vi.fn((e) => e.message),
}));
vi.mock('./workflowExecution.js', () => ({
executeWorkflow: vi.fn(),
}));
vi.mock('../constants.js', () => ({
DEFAULT_WORKFLOW_NAME: 'default',
DEFAULT_LANGUAGE: 'en',
}));
import { createWorktree } from '../task/worktree.js';
import { summarizeTaskName } from '../task/summarize.js';
import { info } from '../utils/ui.js';
import { resolveTaskExecution } from '../commands/taskExecution.js';
import type { TaskInfo } from '../task/index.js';
const mockCreateWorktree = vi.mocked(createWorktree);
const mockSummarizeTaskName = vi.mocked(summarizeTaskName);
const mockInfo = vi.mocked(info);
beforeEach(() => {
vi.clearAllMocks();
});
describe('resolveTaskExecution', () => {
it('should return defaults when task has no data', async () => {
// Given: Task without structured data
const task: TaskInfo = {
name: 'simple-task',
content: 'Simple task content',
filePath: '/tasks/simple-task.yaml',
};
// When
const result = await resolveTaskExecution(task, '/project', 'default');
// Then
expect(result).toEqual({
execCwd: '/project',
execWorkflow: 'default',
isWorktree: false,
});
expect(mockSummarizeTaskName).not.toHaveBeenCalled();
expect(mockCreateWorktree).not.toHaveBeenCalled();
});
it('should return defaults when data has no worktree option', async () => {
// Given: Task with data but no worktree
const task: TaskInfo = {
name: 'task-with-data',
content: 'Task content',
filePath: '/tasks/task.yaml',
data: {
task: 'Task content',
},
};
// When
const result = await resolveTaskExecution(task, '/project', 'default');
// Then
expect(result.isWorktree).toBe(false);
expect(mockSummarizeTaskName).not.toHaveBeenCalled();
});
it('should create worktree with AI-summarized slug when worktree option is true', async () => {
// Given: Task with worktree option
const task: TaskInfo = {
name: 'japanese-task',
content: '認証機能を追加する',
filePath: '/tasks/japanese-task.yaml',
data: {
task: '認証機能を追加する',
worktree: true,
},
};
mockSummarizeTaskName.mockResolvedValue('add-auth');
mockCreateWorktree.mockReturnValue({
path: '/project/.takt/worktrees/20260128T0504-add-auth',
branch: 'takt/20260128T0504-add-auth',
});
// When
const result = await resolveTaskExecution(task, '/project', 'default');
// Then
expect(mockSummarizeTaskName).toHaveBeenCalledWith('認証機能を追加する', { cwd: '/project' });
expect(mockCreateWorktree).toHaveBeenCalledWith('/project', {
worktree: true,
branch: undefined,
taskSlug: 'add-auth',
});
expect(result).toEqual({
execCwd: '/project/.takt/worktrees/20260128T0504-add-auth',
execWorkflow: 'default',
isWorktree: true,
});
});
it('should display generating message before AI call', async () => {
// Given: Task with worktree
const task: TaskInfo = {
name: 'test-task',
content: 'Test task',
filePath: '/tasks/test.yaml',
data: {
task: 'Test task',
worktree: true,
},
};
mockSummarizeTaskName.mockResolvedValue('test-task');
mockCreateWorktree.mockReturnValue({
path: '/project/.takt/worktrees/test-task',
branch: 'takt/test-task',
});
// When
await resolveTaskExecution(task, '/project', 'default');
// Then
expect(mockInfo).toHaveBeenCalledWith('Generating branch name...');
});
it('should use task content (not name) for AI summarization', async () => {
// Given: Task where name differs from content
const task: TaskInfo = {
name: 'old-file-name',
content: 'New feature implementation details',
filePath: '/tasks/old-file-name.yaml',
data: {
task: 'New feature implementation details',
worktree: true,
},
};
mockSummarizeTaskName.mockResolvedValue('new-feature');
mockCreateWorktree.mockReturnValue({
path: '/project/.takt/worktrees/new-feature',
branch: 'takt/new-feature',
});
// When
await resolveTaskExecution(task, '/project', 'default');
// Then: Should use content, not file name
expect(mockSummarizeTaskName).toHaveBeenCalledWith('New feature implementation details', { cwd: '/project' });
});
it('should use workflow override from task data', async () => {
// Given: Task with workflow override
const task: TaskInfo = {
name: 'task-with-workflow',
content: 'Task content',
filePath: '/tasks/task.yaml',
data: {
task: 'Task content',
workflow: 'custom-workflow',
},
};
// When
const result = await resolveTaskExecution(task, '/project', 'default');
// Then
expect(result.execWorkflow).toBe('custom-workflow');
});
it('should pass branch option to createWorktree when specified', async () => {
// Given: Task with custom branch
const task: TaskInfo = {
name: 'task-with-branch',
content: 'Task content',
filePath: '/tasks/task.yaml',
data: {
task: 'Task content',
worktree: true,
branch: 'feature/custom-branch',
},
};
mockSummarizeTaskName.mockResolvedValue('custom-task');
mockCreateWorktree.mockReturnValue({
path: '/project/.takt/worktrees/custom-task',
branch: 'feature/custom-branch',
});
// When
await resolveTaskExecution(task, '/project', 'default');
// Then
expect(mockCreateWorktree).toHaveBeenCalledWith('/project', {
worktree: true,
branch: 'feature/custom-branch',
taskSlug: 'custom-task',
});
});
it('should display worktree creation info', async () => {
// Given: Task with worktree
const task: TaskInfo = {
name: 'info-task',
content: 'Info task',
filePath: '/tasks/info.yaml',
data: {
task: 'Info task',
worktree: true,
},
};
mockSummarizeTaskName.mockResolvedValue('info-task');
mockCreateWorktree.mockReturnValue({
path: '/project/.takt/worktrees/20260128-info-task',
branch: 'takt/20260128-info-task',
});
// When
await resolveTaskExecution(task, '/project', 'default');
// Then
expect(mockInfo).toHaveBeenCalledWith(
'Worktree created: /project/.takt/worktrees/20260128-info-task (branch: takt/20260128-info-task)'
);
});
});

View File

@ -10,7 +10,7 @@ import * as path from 'node:path';
import { stringify as stringifyYaml } from 'yaml';
import { promptInput, promptMultilineInput, confirm, selectOption } from '../prompt/index.js';
import { success, info } from '../utils/ui.js';
import { slugify } from '../utils/slug.js';
import { summarizeTaskName } from '../task/summarize.js';
import { createLogger } from '../utils/debug.js';
import { listWorkflows } from '../config/workflowLoader.js';
import { getCurrentWorkflow } from '../config/paths.js';
@ -19,10 +19,11 @@ import type { TaskFileData } from '../task/schema.js';
const log = createLogger('add-task');
/**
* Generate a unique task filename
* Generate a unique task filename with AI-summarized slug
*/
function generateFilename(tasksDir: string, taskContent: string): string {
const slug = slugify(taskContent);
async function generateFilename(tasksDir: string, taskContent: string, cwd: string): Promise<string> {
info('Generating task filename...');
const slug = await summarizeTaskName(taskContent, { cwd });
const base = slug || 'task';
let filename = `${base}.yaml`;
let counter = 1;
@ -112,7 +113,7 @@ export async function addTask(cwd: string, args: string[]): Promise<void> {
// Write YAML file (use first line for filename to keep it short)
const firstLine = taskContent.split('\n')[0] || taskContent;
const filename = generateFilename(tasksDir, firstLine);
const filename = await generateFilename(tasksDir, firstLine, cwd);
const filePath = path.join(tasksDir, filename);
const yamlContent = stringifyYaml(taskData);
fs.writeFileSync(filePath, yamlContent, 'utf-8');

View File

@ -6,6 +6,7 @@ import { loadWorkflow, loadGlobalConfig } from '../config/index.js';
import { TaskRunner, type TaskInfo } from '../task/index.js';
import { createWorktree } from '../task/worktree.js';
import { autoCommitWorktree } from '../task/autoCommit.js';
import { summarizeTaskName } from '../task/summarize.js';
import {
header,
info,
@ -73,7 +74,7 @@ export async function executeAndCompleteTask(
const executionLog: string[] = [];
try {
const { execCwd, execWorkflow, isWorktree } = resolveTaskExecution(task, cwd, workflowName);
const { execCwd, execWorkflow, isWorktree } = await resolveTaskExecution(task, cwd, workflowName);
// cwd is always the project root; pass it as projectCwd so reports/sessions go there
const taskSuccess = await executeTask(task.content, execCwd, execWorkflow, cwd);
@ -179,12 +180,13 @@ export async function runAllTasks(
/**
* Resolve execution directory and workflow from task data.
* If the task has worktree settings, create a worktree and use it as cwd.
* Task name is summarized to English by AI for use in branch/worktree names.
*/
export function resolveTaskExecution(
export async function resolveTaskExecution(
task: TaskInfo,
defaultCwd: string,
defaultWorkflow: string
): { execCwd: string; execWorkflow: string; isWorktree: boolean } {
): Promise<{ execCwd: string; execWorkflow: string; isWorktree: boolean }> {
const data = task.data;
// No structured data: use defaults
@ -197,10 +199,14 @@ export function resolveTaskExecution(
// Handle worktree
if (data.worktree) {
// Summarize task content to English slug using AI
info('Generating branch name...');
const taskSlug = await summarizeTaskName(task.content, { cwd: defaultCwd });
const result = createWorktree(defaultCwd, {
worktree: data.worktree,
branch: data.branch,
taskSlug: task.name,
taskSlug,
});
execCwd = result.path;
isWorktree = true;

View File

@ -1,66 +1,104 @@
/**
* Task name summarization using AI
* Task name summarization using AI or romanization
*
* Generates concise English summaries for use in branch names and worktree paths.
* Generates concise English/romaji summaries for use in branch names and worktree paths.
*/
import { callClaude } from '../claude/client.js';
import * as wanakana from 'wanakana';
import { loadGlobalConfig } from '../config/globalConfig.js';
import { getProvider, type ProviderType } from '../providers/index.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.
/**
* Sanitize a string for use as git branch name and directory name.
*
* Git branch restrictions: no spaces, ~, ^, :, ?, *, [, \, .., @{, leading -
* Directory restrictions: no /, \, :, *, ?, ", <, >, |
*
* This function allows only: a-z, 0-9, hyphen
*/
function sanitizeSlug(input: string, maxLength = 30): string {
return input
.trim()
.toLowerCase()
.replace(/[^a-z0-9-]/g, '-') // Replace invalid chars with hyphen
.replace(/-+/g, '-') // Collapse multiple hyphens
.replace(/^-+/, '') // Remove leading hyphens
.slice(0, maxLength)
.replace(/-+$/, ''); // Remove trailing hyphens (after slice)
}
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
const SUMMARIZE_SYSTEM_PROMPT = `You are a slug generator. Given a task description, output ONLY a slug.
Examples:
- "認証機能を追加する" "add-auth"
- "Fix the login bug" "fix-login-bug"
- "worktreeを作るときブランチ名をAIで生成" "ai-branch-naming"
- "Add user registration with email verification" "add-user-registration"`;
NEVER output sentences. NEVER start with "this", "the", "i", "we", or "it".
ALWAYS start with a verb: add, fix, update, refactor, implement, remove, etc.
Format: verb-noun (lowercase, hyphens, max 30 chars)
Input Output:
add-auth
Fix the login bug fix-login-bug
add-email-verification
worktreeを作るときブランチ名をAIで生成 ai-branch-naming
show-original-instruction`;
export interface SummarizeOptions {
/** Working directory for Claude execution */
cwd: string;
/** Model to use (optional, defaults to haiku for speed) */
/** Model to use (optional, defaults to config or haiku) */
model?: string;
/** Use LLM for summarization (default: true). If false, uses romanization. */
useLLM?: boolean;
}
/**
* Summarize a task name into a concise English slug using AI.
* Convert Japanese text to romaji slug.
* Handles hiragana, katakana, and passes through alphanumeric.
* Kanji and other non-convertible characters become hyphens.
*/
function toRomajiSlug(text: string): string {
// Convert to romaji (hiragana/katakana → romaji, kanji stays as-is)
const romaji = wanakana.toRomaji(text, { customRomajiMapping: {} });
return sanitizeSlug(romaji);
}
/**
* Summarize a task name into a concise slug using AI or romanization.
*
* @param taskName - Original task name (can be in any language)
* @param options - Summarization options
* @returns English slug suitable for branch names
* @returns Slug suitable for branch names (English if LLM, romaji if not)
*/
export async function summarizeTaskName(
taskName: string,
options: SummarizeOptions
): Promise<string> {
log.info('Summarizing task name', { taskName });
const useLLM = options.useLLM ?? true;
log.info('Summarizing task name', { taskName, useLLM });
const response = await callClaude('summarizer', `Summarize this task: "${taskName}"`, {
// Use romanization if LLM is disabled
if (!useLLM) {
const slug = toRomajiSlug(taskName);
log.info('Task name romanized', { original: taskName, slug });
return slug || 'task';
}
// Use LLM for summarization
const globalConfig = loadGlobalConfig();
const providerType = (globalConfig.provider as ProviderType) ?? 'claude';
const model = options.model ?? globalConfig.model ?? 'haiku';
const provider = getProvider(providerType);
const response = await provider.call('summarizer', taskName, {
cwd: options.cwd,
model: options.model ?? 'haiku',
maxTurns: 1,
model,
systemPrompt: SUMMARIZE_SYSTEM_PROMPT,
allowedTools: [],
});
const slug = response.content
.trim()
.toLowerCase()
.replace(/[^a-z0-9-]/g, '-')
.replace(/-+/g, '-')
.replace(/^-|-$/g, '')
.slice(0, 30);
const slug = sanitizeSlug(response.content);
log.info('Task name summarized', { original: taskName, slug });
return slug || 'task';