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:
parent
1f636bff23
commit
f83b826a3d
21
package-lock.json
generated
21
package-lock.json
generated
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
193
src/__tests__/addTask.test.ts
Normal file
193
src/__tests__/addTask.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
273
src/__tests__/taskExecution.test.ts
Normal file
273
src/__tests__/taskExecution.test.ts
Normal 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)'
|
||||
);
|
||||
});
|
||||
});
|
||||
@ -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');
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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';
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user