diff --git a/package-lock.json b/package-lock.json index 116ce03..e08375d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 258f3af..89d5624 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/__tests__/addTask.test.ts b/src/__tests__/addTask.test.ts new file mode 100644 index 0000000..119cbce --- /dev/null +++ b/src/__tests__/addTask.test.ts @@ -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'); + }); +}); diff --git a/src/__tests__/summarize.test.ts b/src/__tests__/summarize.test.ts index d350be2..556528d 100644 --- a/src/__tests__/summarize.test.ts +++ b/src/__tests__/summarize.test.ts @@ -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(); + }); }); diff --git a/src/__tests__/taskExecution.test.ts b/src/__tests__/taskExecution.test.ts new file mode 100644 index 0000000..b57b5d1 --- /dev/null +++ b/src/__tests__/taskExecution.test.ts @@ -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)' + ); + }); +}); diff --git a/src/commands/addTask.ts b/src/commands/addTask.ts index 687f631..4efc153 100644 --- a/src/commands/addTask.ts +++ b/src/commands/addTask.ts @@ -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 { + 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 { // 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'); diff --git a/src/commands/taskExecution.ts b/src/commands/taskExecution.ts index fea024f..b14ec1f 100644 --- a/src/commands/taskExecution.ts +++ b/src/commands/taskExecution.ts @@ -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; diff --git a/src/task/summarize.ts b/src/task/summarize.ts index c6a5796..3bda41b 100644 --- a/src/task/summarize.ts +++ b/src/task/summarize.ts @@ -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 { - 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';