takt: add-ai-consultation-actions

This commit is contained in:
nrslib 2026-02-05 23:37:00 +09:00
parent 653b55a034
commit c044aea33f
18 changed files with 801 additions and 95 deletions

View File

@ -38,7 +38,7 @@ sequenceDiagram
User->>Interactive: /go コマンド
Interactive->>Interactive: buildTaskFromHistory()
Interactive-->>CLI: { confirmed: true, task: string }
Interactive-->>CLI: { action: InteractiveModeAction, task: string }
CLI->>Orchestration: selectAndExecuteTask(cwd, task)

View File

@ -355,7 +355,7 @@ TAKTのデータフローは以下の7つの主要なレイヤーで構成され
**データ出力**:
- `InteractiveModeResult`:
- `confirmed: boolean`
- `action: InteractiveModeAction` (`'execute' | 'save_task' | 'create_issue' | 'cancel'`)
- `task: string` (会話履歴全体を結合した文字列)
---

View File

@ -70,6 +70,7 @@ vi.mock('../infra/github/issue.js', () => ({
}
return numbers;
}),
createIssue: vi.fn(),
}));
import { interactiveMode } from '../features/interactive/index.js';
@ -77,10 +78,11 @@ import { promptInput, confirm } from '../shared/prompt/index.js';
import { summarizeTaskName } from '../infra/task/summarize.js';
import { determinePiece } from '../features/tasks/execute/selectAndExecute.js';
import { getPieceDescription } from '../infra/config/loaders/pieceResolver.js';
import { resolveIssueTask } from '../infra/github/issue.js';
import { resolveIssueTask, createIssue } from '../infra/github/issue.js';
import { addTask } from '../features/tasks/index.js';
const mockResolveIssueTask = vi.mocked(resolveIssueTask);
const mockCreateIssue = vi.mocked(createIssue);
const mockInteractiveMode = vi.mocked(interactiveMode);
const mockPromptInput = vi.mocked(promptInput);
const mockConfirm = vi.mocked(confirm);
@ -97,7 +99,7 @@ function setupFullFlowMocks(overrides?: {
mockDeterminePiece.mockResolvedValue('default');
mockGetPieceDescription.mockReturnValue({ name: 'default', description: '', pieceStructure: '' });
mockInteractiveMode.mockResolvedValue({ confirmed: true, task });
mockInteractiveMode.mockResolvedValue({ action: 'execute', task });
mockSummarizeTaskName.mockResolvedValue(slug);
mockConfirm.mockResolvedValue(false);
}
@ -122,7 +124,7 @@ describe('addTask', () => {
it('should cancel when interactive mode is not confirmed', async () => {
// Given: user cancels interactive mode
mockDeterminePiece.mockResolvedValue('default');
mockInteractiveMode.mockResolvedValue({ confirmed: false, task: '' });
mockInteractiveMode.mockResolvedValue({ action: 'cancel', task: '' });
// When
await addTask(testDir);
@ -341,4 +343,51 @@ describe('addTask', () => {
const content = fs.readFileSync(taskFile, 'utf-8');
expect(content).toContain('issue: 99');
});
describe('create_issue action', () => {
it('should call createIssue when create_issue action is selected', async () => {
// Given: interactive mode returns create_issue action
const task = 'Create a new feature\nWith detailed description';
mockDeterminePiece.mockResolvedValue('default');
mockInteractiveMode.mockResolvedValue({ action: 'create_issue', task });
mockCreateIssue.mockReturnValue({ success: true, url: 'https://github.com/owner/repo/issues/1' });
// When
await addTask(testDir);
// Then: createIssue is called via createIssueFromTask
expect(mockCreateIssue).toHaveBeenCalledWith({
title: 'Create a new feature',
body: task,
});
});
it('should not create task file when create_issue action is selected', async () => {
// Given: interactive mode returns create_issue action
mockDeterminePiece.mockResolvedValue('default');
mockInteractiveMode.mockResolvedValue({ action: 'create_issue', task: 'Some task' });
mockCreateIssue.mockReturnValue({ success: true, url: 'https://github.com/owner/repo/issues/1' });
// When
await addTask(testDir);
// Then: no task file created
const tasksDir = path.join(testDir, '.takt', 'tasks');
const files = fs.existsSync(tasksDir) ? fs.readdirSync(tasksDir) : [];
expect(files.length).toBe(0);
});
it('should not prompt for worktree settings when create_issue action is selected', async () => {
// Given: interactive mode returns create_issue action
mockDeterminePiece.mockResolvedValue('default');
mockInteractiveMode.mockResolvedValue({ action: 'create_issue', task: 'Some task' });
mockCreateIssue.mockReturnValue({ success: true, url: 'https://github.com/owner/repo/issues/1' });
// When
await addTask(testDir);
// Then: confirm (worktree prompt) is never called
expect(mockConfirm).not.toHaveBeenCalled();
});
});
});

View File

@ -0,0 +1,137 @@
/**
* Tests for createIssue function
*
* createIssue uses `gh issue create` via execFileSync, which is an
* integration concern. Tests focus on argument construction and error handling
* by mocking child_process.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { execFileSync } from 'node:child_process';
vi.mock('node:child_process', () => ({
execFileSync: vi.fn(),
}));
vi.mock('../../shared/utils/index.js', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
createLogger: () => ({
info: vi.fn(),
debug: vi.fn(),
error: vi.fn(),
}),
}));
import { createIssue, checkGhCli } from '../infra/github/issue.js';
const mockExecFileSync = vi.mocked(execFileSync);
beforeEach(() => {
vi.clearAllMocks();
});
describe('createIssue', () => {
it('should return success with URL when gh issue create succeeds', () => {
// Given: gh auth and issue creation both succeed
mockExecFileSync
.mockReturnValueOnce(Buffer.from('')) // gh auth status
.mockReturnValueOnce('https://github.com/owner/repo/issues/42\n' as unknown as Buffer);
// When
const result = createIssue({ title: 'Test issue', body: 'Test body' });
// Then
expect(result.success).toBe(true);
expect(result.url).toBe('https://github.com/owner/repo/issues/42');
});
it('should pass title and body as arguments', () => {
// Given
mockExecFileSync
.mockReturnValueOnce(Buffer.from('')) // gh auth status
.mockReturnValueOnce('https://github.com/owner/repo/issues/1\n' as unknown as Buffer);
// When
createIssue({ title: 'My Title', body: 'My Body' });
// Then: verify the second call (issue create) has correct args
const issueCreateCall = mockExecFileSync.mock.calls[1];
expect(issueCreateCall?.[0]).toBe('gh');
expect(issueCreateCall?.[1]).toEqual([
'issue', 'create', '--title', 'My Title', '--body', 'My Body',
]);
});
it('should include labels when provided', () => {
// Given
mockExecFileSync
.mockReturnValueOnce(Buffer.from('')) // gh auth status
.mockReturnValueOnce('https://github.com/owner/repo/issues/1\n' as unknown as Buffer);
// When
createIssue({ title: 'Bug', body: 'Fix it', labels: ['bug', 'priority:high'] });
// Then
const issueCreateCall = mockExecFileSync.mock.calls[1];
expect(issueCreateCall?.[1]).toEqual([
'issue', 'create', '--title', 'Bug', '--body', 'Fix it',
'--label', 'bug,priority:high',
]);
});
it('should not include --label when labels is empty', () => {
// Given
mockExecFileSync
.mockReturnValueOnce(Buffer.from('')) // gh auth status
.mockReturnValueOnce('https://github.com/owner/repo/issues/1\n' as unknown as Buffer);
// When
createIssue({ title: 'Title', body: 'Body', labels: [] });
// Then
const issueCreateCall = mockExecFileSync.mock.calls[1];
expect(issueCreateCall?.[1]).not.toContain('--label');
});
it('should return error when gh CLI is not authenticated', () => {
// Given: auth fails, version succeeds
mockExecFileSync
.mockImplementationOnce(() => { throw new Error('not authenticated'); })
.mockReturnValueOnce(Buffer.from('gh version 2.0.0'));
// When
const result = createIssue({ title: 'Test', body: 'Body' });
// Then
expect(result.success).toBe(false);
expect(result.error).toContain('not authenticated');
});
it('should return error when gh CLI is not installed', () => {
// Given: both auth and version fail
mockExecFileSync
.mockImplementationOnce(() => { throw new Error('command not found'); })
.mockImplementationOnce(() => { throw new Error('command not found'); });
// When
const result = createIssue({ title: 'Test', body: 'Body' });
// Then
expect(result.success).toBe(false);
expect(result.error).toContain('not installed');
});
it('should return error when gh issue create fails', () => {
// Given: auth succeeds but issue creation fails
mockExecFileSync
.mockReturnValueOnce(Buffer.from('')) // gh auth status
.mockImplementationOnce(() => { throw new Error('repo not found'); });
// When
const result = createIssue({ title: 'Test', body: 'Body' });
// Then
expect(result.success).toBe(false);
expect(result.error).toContain('repo not found');
});
});

View File

@ -0,0 +1,131 @@
/**
* Tests for createIssueFromTask function
*
* Verifies title truncation (100-char boundary), success/failure UI output,
* and multi-line task handling (first line title, full text body).
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('../infra/github/issue.js', () => ({
createIssue: vi.fn(),
}));
vi.mock('../shared/ui/index.js', () => ({
success: vi.fn(),
info: vi.fn(),
error: vi.fn(),
}));
vi.mock('../shared/utils/index.js', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
createLogger: () => ({
info: vi.fn(),
debug: vi.fn(),
error: vi.fn(),
}),
}));
import { createIssue } from '../infra/github/issue.js';
import { success, error } from '../shared/ui/index.js';
import { createIssueFromTask } from '../features/tasks/index.js';
const mockCreateIssue = vi.mocked(createIssue);
const mockSuccess = vi.mocked(success);
const mockError = vi.mocked(error);
beforeEach(() => {
vi.clearAllMocks();
});
describe('createIssueFromTask', () => {
describe('title truncation boundary', () => {
it('should use title as-is when exactly 99 characters', () => {
// Given: 99-character first line
const title99 = 'a'.repeat(99);
mockCreateIssue.mockReturnValue({ success: true, url: 'https://github.com/owner/repo/issues/1' });
// When
createIssueFromTask(title99);
// Then: title passed without truncation
expect(mockCreateIssue).toHaveBeenCalledWith({
title: title99,
body: title99,
});
});
it('should use title as-is when exactly 100 characters', () => {
// Given: 100-character first line
const title100 = 'a'.repeat(100);
mockCreateIssue.mockReturnValue({ success: true, url: 'https://github.com/owner/repo/issues/1' });
// When
createIssueFromTask(title100);
// Then: title passed without truncation
expect(mockCreateIssue).toHaveBeenCalledWith({
title: title100,
body: title100,
});
});
it('should truncate title to 97 chars + ellipsis when 101 characters', () => {
// Given: 101-character first line
const title101 = 'a'.repeat(101);
mockCreateIssue.mockReturnValue({ success: true, url: 'https://github.com/owner/repo/issues/1' });
// When
createIssueFromTask(title101);
// Then: title truncated to 97 chars + "..."
const expectedTitle = `${'a'.repeat(97)}...`;
expect(expectedTitle).toHaveLength(100);
expect(mockCreateIssue).toHaveBeenCalledWith({
title: expectedTitle,
body: title101,
});
});
});
it('should display success message with URL when issue creation succeeds', () => {
// Given
const url = 'https://github.com/owner/repo/issues/42';
mockCreateIssue.mockReturnValue({ success: true, url });
// When
createIssueFromTask('Test task');
// Then
expect(mockSuccess).toHaveBeenCalledWith(`Issue created: ${url}`);
expect(mockError).not.toHaveBeenCalled();
});
it('should display error message when issue creation fails', () => {
// Given
const errorMsg = 'repo not found';
mockCreateIssue.mockReturnValue({ success: false, error: errorMsg });
// When
createIssueFromTask('Test task');
// Then
expect(mockError).toHaveBeenCalledWith(`Failed to create issue: ${errorMsg}`);
expect(mockSuccess).not.toHaveBeenCalled();
});
it('should use first line as title and full text as body for multi-line task', () => {
// Given: multi-line task
const task = 'First line title\nSecond line details\nThird line more info';
mockCreateIssue.mockReturnValue({ success: true, url: 'https://github.com/owner/repo/issues/1' });
// When
createIssueFromTask(task);
// Then: first line → title, full text → body
expect(mockCreateIssue).toHaveBeenCalledWith({
title: 'First line title',
body: task,
});
});
});

View File

@ -96,7 +96,8 @@ describe('label integrity', () => {
expect(ui).toHaveProperty('summarizeFailed');
expect(ui).toHaveProperty('continuePrompt');
expect(ui).toHaveProperty('proposed');
expect(ui).toHaveProperty('confirm');
expect(ui).toHaveProperty('actionPrompt');
expect(ui).toHaveProperty('actions');
expect(ui).toHaveProperty('cancelled');
});

View File

@ -123,11 +123,12 @@ function setupMockProvider(responses: string[]): void {
beforeEach(() => {
vi.clearAllMocks();
mockSelectOption.mockResolvedValue('yes');
// selectPostSummaryAction uses selectOption with action values
mockSelectOption.mockResolvedValue('execute');
});
describe('interactiveMode', () => {
it('should return confirmed=false when user types /cancel', async () => {
it('should return action=cancel when user types /cancel', async () => {
// Given
setupInputSequence(['/cancel']);
setupMockProvider([]);
@ -136,11 +137,11 @@ describe('interactiveMode', () => {
const result = await interactiveMode('/project');
// Then
expect(result.confirmed).toBe(false);
expect(result.action).toBe('cancel');
expect(result.task).toBe('');
});
it('should return confirmed=false on EOF (Ctrl+D)', async () => {
it('should return action=cancel on EOF (Ctrl+D)', async () => {
// Given
setupInputSequence([null]);
setupMockProvider([]);
@ -149,7 +150,7 @@ describe('interactiveMode', () => {
const result = await interactiveMode('/project');
// Then
expect(result.confirmed).toBe(false);
expect(result.action).toBe('cancel');
});
it('should call provider with allowed tools for codebase exploration', async () => {
@ -172,7 +173,7 @@ describe('interactiveMode', () => {
);
});
it('should return confirmed=true with task on /go after conversation', async () => {
it('should return action=execute with task on /go after conversation', async () => {
// Given
setupInputSequence(['add auth feature', '/go']);
setupMockProvider(['What kind of authentication?', 'Implement auth feature with chosen method.']);
@ -181,7 +182,7 @@ describe('interactiveMode', () => {
const result = await interactiveMode('/project');
// Then
expect(result.confirmed).toBe(true);
expect(result.action).toBe('execute');
expect(result.task).toBe('Implement auth feature with chosen method.');
});
@ -193,8 +194,8 @@ describe('interactiveMode', () => {
// When
const result = await interactiveMode('/project');
// Then: should not confirm (fell through to /cancel)
expect(result.confirmed).toBe(false);
// Then: should cancel (fell through to /cancel)
expect(result.action).toBe('cancel');
});
it('should skip empty input', async () => {
@ -206,7 +207,7 @@ describe('interactiveMode', () => {
const result = await interactiveMode('/project');
// Then
expect(result.confirmed).toBe(true);
expect(result.action).toBe('execute');
const mockProvider = mockGetProvider.mock.results[0]!.value as { call: ReturnType<typeof vi.fn> };
expect(mockProvider.call).toHaveBeenCalledTimes(2);
});
@ -220,7 +221,7 @@ describe('interactiveMode', () => {
const result = await interactiveMode('/project');
// Then: task should be a summary and prompt should include full history
expect(result.confirmed).toBe(true);
expect(result.action).toBe('execute');
expect(result.task).toBe('Summarized task.');
const mockProvider = mockGetProvider.mock.results[0]!.value as { call: ReturnType<typeof vi.fn> };
const summaryPrompt = mockProvider.call.mock.calls[2]?.[1] as string;
@ -259,7 +260,7 @@ describe('interactiveMode', () => {
expect(mockProvider.call.mock.calls[0]?.[1]).toBe('a');
// /go should work because initialInput already started conversation
expect(result.confirmed).toBe(true);
expect(result.action).toBe('execute');
expect(result.task).toBe('Clarify task for "a".');
});
@ -278,12 +279,12 @@ describe('interactiveMode', () => {
expect(mockProvider.call.mock.calls[1]?.[1]).toBe('fix the login page');
// Task still contains all history for downstream use
expect(result.confirmed).toBe(true);
expect(result.action).toBe('execute');
expect(result.task).toBe('Fix login page with clarified scope.');
});
describe('/play command', () => {
it('should return confirmed=true with task on /play command', async () => {
it('should return action=execute with task on /play command', async () => {
// Given
setupInputSequence(['/play implement login feature']);
setupMockProvider([]);
@ -292,7 +293,7 @@ describe('interactiveMode', () => {
const result = await interactiveMode('/project');
// Then
expect(result.confirmed).toBe(true);
expect(result.action).toBe('execute');
expect(result.task).toBe('implement login feature');
});
@ -304,8 +305,8 @@ describe('interactiveMode', () => {
// When
const result = await interactiveMode('/project');
// Then: should not confirm (fell through to /cancel)
expect(result.confirmed).toBe(false);
// Then: should cancel (fell through to /cancel)
expect(result.action).toBe('cancel');
});
it('should handle /play with leading/trailing spaces', async () => {
@ -317,7 +318,7 @@ describe('interactiveMode', () => {
const result = await interactiveMode('/project');
// Then
expect(result.confirmed).toBe(true);
expect(result.action).toBe('execute');
expect(result.task).toBe('test task');
});
@ -332,8 +333,64 @@ describe('interactiveMode', () => {
// Then: provider should NOT have been called (no summary needed)
const mockProvider = mockGetProvider.mock.results[0]?.value as { call: ReturnType<typeof vi.fn> };
expect(mockProvider.call).not.toHaveBeenCalled();
expect(result.confirmed).toBe(true);
expect(result.action).toBe('execute');
expect(result.task).toBe('quick task');
});
});
describe('action selection after /go', () => {
it('should return action=create_issue when user selects create issue', async () => {
// Given
setupInputSequence(['describe task', '/go']);
setupMockProvider(['response', 'Summarized task.']);
mockSelectOption.mockResolvedValue('create_issue');
// When
const result = await interactiveMode('/project');
// Then
expect(result.action).toBe('create_issue');
expect(result.task).toBe('Summarized task.');
});
it('should return action=save_task when user selects save task', async () => {
// Given
setupInputSequence(['describe task', '/go']);
setupMockProvider(['response', 'Summarized task.']);
mockSelectOption.mockResolvedValue('save_task');
// When
const result = await interactiveMode('/project');
// Then
expect(result.action).toBe('save_task');
expect(result.task).toBe('Summarized task.');
});
it('should continue editing when user selects continue', async () => {
// Given: user selects 'continue' first, then cancels
setupInputSequence(['describe task', '/go', '/cancel']);
setupMockProvider(['response', 'Summarized task.']);
mockSelectOption.mockResolvedValueOnce('continue');
// When
const result = await interactiveMode('/project');
// Then: should fall through to /cancel
expect(result.action).toBe('cancel');
});
it('should continue editing when user presses ESC (null)', async () => {
// Given: selectOption returns null (ESC), then user cancels
setupInputSequence(['describe task', '/go', '/cancel']);
setupMockProvider(['response', 'Summarized task.']);
mockSelectOption.mockResolvedValueOnce(null);
// When
const result = await interactiveMode('/project');
// Then: should fall through to /cancel
expect(result.action).toBe('cancel');
});
});
});

View File

@ -0,0 +1,186 @@
/**
* Tests for saveTaskFile and saveTaskFromInteractive
*/
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';
vi.mock('../infra/task/summarize.js', () => ({
summarizeTaskName: vi.fn(),
}));
vi.mock('../shared/ui/index.js', () => ({
success: vi.fn(),
info: vi.fn(),
blankLine: vi.fn(),
}));
vi.mock('../shared/utils/index.js', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
createLogger: () => ({
info: vi.fn(),
debug: vi.fn(),
error: vi.fn(),
}),
}));
import { summarizeTaskName } from '../infra/task/summarize.js';
import { success, info } from '../shared/ui/index.js';
import { saveTaskFile, saveTaskFromInteractive } from '../features/tasks/add/index.js';
const mockSummarizeTaskName = vi.mocked(summarizeTaskName);
const mockSuccess = vi.mocked(success);
const mockInfo = vi.mocked(info);
let testDir: string;
beforeEach(() => {
vi.clearAllMocks();
testDir = fs.mkdtempSync(path.join(tmpdir(), 'takt-test-save-'));
mockSummarizeTaskName.mockResolvedValue('test-task');
});
afterEach(() => {
if (testDir && fs.existsSync(testDir)) {
fs.rmSync(testDir, { recursive: true });
}
});
describe('saveTaskFile', () => {
it('should create task file with correct YAML content', async () => {
// Given
const taskContent = 'Implement feature X\nDetails here';
// When
const filePath = await saveTaskFile(testDir, taskContent);
// Then
expect(fs.existsSync(filePath)).toBe(true);
const content = fs.readFileSync(filePath, 'utf-8');
expect(content).toContain('Implement feature X');
expect(content).toContain('Details here');
});
it('should create .takt/tasks directory if it does not exist', async () => {
// Given
const tasksDir = path.join(testDir, '.takt', 'tasks');
expect(fs.existsSync(tasksDir)).toBe(false);
// When
await saveTaskFile(testDir, 'Task content');
// Then
expect(fs.existsSync(tasksDir)).toBe(true);
});
it('should include piece in YAML when specified', async () => {
// When
const filePath = await saveTaskFile(testDir, 'Task', { piece: 'review' });
// Then
const content = fs.readFileSync(filePath, 'utf-8');
expect(content).toContain('piece: review');
});
it('should include issue number in YAML when specified', async () => {
// When
const filePath = await saveTaskFile(testDir, 'Task', { issue: 42 });
// Then
const content = fs.readFileSync(filePath, 'utf-8');
expect(content).toContain('issue: 42');
});
it('should include worktree in YAML when specified', async () => {
// When
const filePath = await saveTaskFile(testDir, 'Task', { worktree: true });
// Then
const content = fs.readFileSync(filePath, 'utf-8');
expect(content).toContain('worktree: true');
});
it('should include branch in YAML when specified', async () => {
// When
const filePath = await saveTaskFile(testDir, 'Task', { branch: 'feat/my-branch' });
// Then
const content = fs.readFileSync(filePath, 'utf-8');
expect(content).toContain('branch: feat/my-branch');
});
it('should not include optional fields when not specified', async () => {
// When
const filePath = await saveTaskFile(testDir, 'Simple task');
// Then
const content = fs.readFileSync(filePath, 'utf-8');
expect(content).not.toContain('piece:');
expect(content).not.toContain('issue:');
expect(content).not.toContain('worktree:');
expect(content).not.toContain('branch:');
});
it('should use first line for filename generation', async () => {
// When
await saveTaskFile(testDir, 'First line\nSecond line');
// Then
expect(mockSummarizeTaskName).toHaveBeenCalledWith('First line', { cwd: testDir });
});
it('should handle duplicate filenames with counter', async () => {
// Given: first file already exists
await saveTaskFile(testDir, 'Task 1');
// When: second file with same slug
const filePath = await saveTaskFile(testDir, 'Task 2');
// Then
expect(path.basename(filePath)).toBe('test-task-1.yaml');
});
});
describe('saveTaskFromInteractive', () => {
it('should save task and display success message', async () => {
// When
await saveTaskFromInteractive(testDir, 'Task content');
// Then
expect(mockSuccess).toHaveBeenCalledWith('Task created: test-task.yaml');
expect(mockInfo).toHaveBeenCalledWith(expect.stringContaining('Path:'));
});
it('should display piece info when specified', async () => {
// When
await saveTaskFromInteractive(testDir, 'Task content', 'review');
// Then
expect(mockInfo).toHaveBeenCalledWith(' Piece: review');
});
it('should include piece in saved YAML', async () => {
// When
await saveTaskFromInteractive(testDir, 'Task content', 'custom');
// Then
const tasksDir = path.join(testDir, '.takt', 'tasks');
const files = fs.readdirSync(tasksDir);
expect(files.length).toBe(1);
const content = fs.readFileSync(path.join(tasksDir, files[0]!), 'utf-8');
expect(content).toContain('piece: custom');
});
it('should not display piece info when not specified', async () => {
// When
await saveTaskFromInteractive(testDir, 'Task content');
// Then
const pieceInfoCalls = mockInfo.mock.calls.filter(
(call) => typeof call[0] === 'string' && call[0].includes('Piece:')
);
expect(pieceInfoCalls.length).toBe(0);
});
});

View File

@ -8,7 +8,7 @@
import { info, error } from '../../shared/ui/index.js';
import { getErrorMessage } from '../../shared/utils/index.js';
import { resolveIssueTask } from '../../infra/github/index.js';
import { selectAndExecuteTask, determinePiece, type SelectAndExecuteOptions } from '../../features/tasks/index.js';
import { selectAndExecuteTask, determinePiece, saveTaskFromInteractive, createIssueFromTask, type SelectAndExecuteOptions } from '../../features/tasks/index.js';
import { executePipeline } from '../../features/pipeline/index.js';
import { interactiveMode } from '../../features/interactive/index.js';
import { getPieceDescription } from '../../infra/config/index.js';
@ -99,14 +99,25 @@ export async function executeDefaultAction(task?: string): Promise<void> {
const pieceContext = getPieceDescription(pieceId, resolvedCwd);
const result = await interactiveMode(resolvedCwd, task, pieceContext);
if (!result.confirmed) {
return;
}
switch (result.action) {
case 'execute':
selectOptions.interactiveUserInput = true;
selectOptions.piece = pieceId;
selectOptions.interactiveMetadata = { confirmed: result.confirmed, task: result.task };
selectOptions.interactiveMetadata = { confirmed: true, task: result.task };
await selectAndExecuteTask(resolvedCwd, result.task, selectOptions, agentOverrides);
break;
case 'create_issue':
createIssueFromTask(result.task);
break;
case 'save_task':
await saveTaskFromInteractive(resolvedCwd, result.task, pieceId);
break;
case 'cancel':
break;
}
}
program

View File

@ -2,4 +2,9 @@
* Interactive mode commands.
*/
export { interactiveMode, type PieceContext, type InteractiveModeResult } from './interactive.js';
export {
interactiveMode,
type PieceContext,
type InteractiveModeResult,
type InteractiveModeAction,
} from './interactive.js';

View File

@ -38,7 +38,13 @@ interface InteractiveUIText {
summarizeFailed: string;
continuePrompt: string;
proposed: string;
confirm: string;
actionPrompt: string;
actions: {
execute: string;
createIssue: string;
saveTask: string;
continue: string;
};
cancelled: string;
playNoTask: string;
}
@ -149,15 +155,23 @@ function buildSummaryPrompt(
});
}
async function confirmTask(task: string, message: string, confirmLabel: string, yesLabel: string, noLabel: string): Promise<boolean> {
type PostSummaryAction = InteractiveModeAction | 'continue';
async function selectPostSummaryAction(
task: string,
proposedLabel: string,
ui: InteractiveUIText,
): Promise<PostSummaryAction | null> {
blankLine();
info(message);
info(proposedLabel);
console.log(task);
const decision = await selectOption(confirmLabel, [
{ label: yesLabel, value: 'yes' },
{ label: noLabel, value: 'no' },
return selectOption<PostSummaryAction>(ui.actionPrompt, [
{ label: ui.actions.execute, value: 'execute' },
{ label: ui.actions.createIssue, value: 'create_issue' },
{ label: ui.actions.saveTask, value: 'save_task' },
{ label: ui.actions.continue, value: 'continue' },
]);
return decision === 'yes';
}
/**
@ -221,10 +235,12 @@ async function callAI(
return { content: response.content, sessionId: response.sessionId, success };
}
export type InteractiveModeAction = 'execute' | 'save_task' | 'create_issue' | 'cancel';
export interface InteractiveModeResult {
/** Whether the user confirmed with /go */
confirmed: boolean;
/** The assembled task text (only meaningful when confirmed=true) */
/** The action selected by the user */
action: InteractiveModeAction;
/** The assembled task text (only meaningful when action is not 'cancel') */
task: string;
}
@ -338,7 +354,7 @@ export async function interactiveMode(
if (!result.success) {
error(result.content);
blankLine();
return { confirmed: false, task: '' };
return { action: 'cancel', task: '' };
}
history.push({ role: 'assistant', content: result.content });
blankLine();
@ -354,7 +370,7 @@ export async function interactiveMode(
if (input === null) {
blankLine();
info('Cancelled');
return { confirmed: false, task: '' };
return { action: 'cancel', task: '' };
}
const trimmed = input.trim();
@ -372,7 +388,7 @@ export async function interactiveMode(
continue;
}
log.info('Play command', { task });
return { confirmed: true, task };
return { action: 'execute', task };
}
if (trimmed.startsWith('/go')) {
@ -400,27 +416,21 @@ export async function interactiveMode(
if (!summaryResult.success) {
error(summaryResult.content);
blankLine();
return { confirmed: false, task: '' };
return { action: 'cancel', task: '' };
}
const task = summaryResult.content.trim();
const confirmed = await confirmTask(
task,
prompts.ui.proposed,
prompts.ui.confirm,
lang === 'ja' ? 'はい' : 'Yes',
lang === 'ja' ? 'いいえ' : 'No',
);
if (!confirmed) {
const selectedAction = await selectPostSummaryAction(task, prompts.ui.proposed, prompts.ui);
if (selectedAction === 'continue' || selectedAction === null) {
info(prompts.ui.continuePrompt);
continue;
}
log.info('Interactive mode confirmed', { messageCount: history.length });
return { confirmed: true, task };
log.info('Interactive mode action selected', { action: selectedAction, messageCount: history.length });
return { action: selectedAction, task };
}
if (trimmed === '/cancel') {
info(prompts.ui.cancelled);
return { confirmed: false, task: '' };
return { action: 'cancel', task: '' };
}
// Regular input — send to AI
@ -436,7 +446,7 @@ export async function interactiveMode(
error(result.content);
blankLine();
history.pop();
return { confirmed: false, task: '' };
return { action: 'cancel', task: '' };
}
history.push({ role: 'assistant', content: result.content });
blankLine();

View File

@ -9,12 +9,12 @@ import * as fs from 'node:fs';
import * as path from 'node:path';
import { stringify as stringifyYaml } from 'yaml';
import { promptInput, confirm } from '../../../shared/prompt/index.js';
import { success, info } from '../../../shared/ui/index.js';
import { success, info, error } from '../../../shared/ui/index.js';
import { summarizeTaskName, type TaskFileData } from '../../../infra/task/index.js';
import { getPieceDescription } from '../../../infra/config/index.js';
import { determinePiece } from '../execute/selectAndExecute.js';
import { createLogger, getErrorMessage } from '../../../shared/utils/index.js';
import { isIssueReference, resolveIssueTask, parseIssueNumbers } from '../../../infra/github/index.js';
import { isIssueReference, resolveIssueTask, parseIssueNumbers, createIssue } from '../../../infra/github/index.js';
import { interactiveMode } from '../../interactive/index.js';
const log = createLogger('add-task');
@ -34,6 +34,74 @@ async function generateFilename(tasksDir: string, taskContent: string, cwd: stri
return filename;
}
/**
* Save a task file to .takt/tasks/ with YAML format.
*
* Common logic extracted from addTask(). Used by both addTask()
* and saveTaskFromInteractive().
*/
export async function saveTaskFile(
cwd: string,
taskContent: string,
options?: { piece?: string; issue?: number; worktree?: boolean | string; branch?: string },
): Promise<string> {
const tasksDir = path.join(cwd, '.takt', 'tasks');
fs.mkdirSync(tasksDir, { recursive: true });
const firstLine = taskContent.split('\n')[0] || taskContent;
const filename = await generateFilename(tasksDir, firstLine, cwd);
const taskData: TaskFileData = {
task: taskContent,
...(options?.worktree !== undefined && { worktree: options.worktree }),
...(options?.branch && { branch: options.branch }),
...(options?.piece && { piece: options.piece }),
...(options?.issue !== undefined && { issue: options.issue }),
};
const filePath = path.join(tasksDir, filename);
const yamlContent = stringifyYaml(taskData);
fs.writeFileSync(filePath, yamlContent, 'utf-8');
log.info('Task created', { filePath, taskData });
return filePath;
}
/**
* Create a GitHub Issue from a task description.
*
* Extracts the first line as the issue title (truncated to 100 chars),
* uses the full task as the body, and displays success/error messages.
*/
export function createIssueFromTask(task: string): void {
info('Creating GitHub Issue...');
const firstLine = task.split('\n')[0] || task;
const title = firstLine.length > 100 ? `${firstLine.slice(0, 97)}...` : firstLine;
const issueResult = createIssue({ title, body: task });
if (issueResult.success) {
success(`Issue created: ${issueResult.url}`);
} else {
error(`Failed to create issue: ${issueResult.error}`);
}
}
/**
* Save a task from interactive mode result.
* Does not prompt for worktree/branch settings.
*/
export async function saveTaskFromInteractive(
cwd: string,
task: string,
piece?: string,
): Promise<void> {
const filePath = await saveTaskFile(cwd, task, { piece });
const filename = path.basename(filePath);
success(`Task created: ${filename}`);
info(` Path: ${filePath}`);
if (piece) info(` Piece: ${piece}`);
}
/**
* add command handler
*
@ -82,7 +150,13 @@ export async function addTask(cwd: string, task?: string): Promise<void> {
// Interactive mode: AI conversation to refine task
const result = await interactiveMode(cwd, undefined, pieceContext);
if (!result.confirmed) {
if (result.action === 'create_issue') {
createIssueFromTask(result.task);
return;
}
if (result.action !== 'execute' && result.action !== 'save_task') {
info('Cancelled.');
return;
}
@ -91,11 +165,7 @@ export async function addTask(cwd: string, task?: string): Promise<void> {
taskContent = result.task;
}
// 3. 要約からファイル名生成
const firstLine = taskContent.split('\n')[0] || taskContent;
const filename = await generateFilename(tasksDir, firstLine, cwd);
// 4. ワークツリー/ブランチ設定
// 3. ワークツリー/ブランチ設定
let worktree: boolean | string | undefined;
let branch: string | undefined;
@ -110,27 +180,15 @@ export async function addTask(cwd: string, task?: string): Promise<void> {
}
}
// 5. YAMLファイル作成
const taskData: TaskFileData = { task: taskContent };
if (worktree !== undefined) {
taskData.worktree = worktree;
}
if (branch) {
taskData.branch = branch;
}
if (piece) {
taskData.piece = piece;
}
if (issueNumber !== undefined) {
taskData.issue = issueNumber;
}
const filePath = path.join(tasksDir, filename);
const yamlContent = stringifyYaml(taskData);
fs.writeFileSync(filePath, yamlContent, 'utf-8');
log.info('Task created', { filePath, taskData });
// 4. YAMLファイル作成
const filePath = await saveTaskFile(cwd, taskContent, {
piece,
issue: issueNumber,
worktree,
branch,
});
const filename = path.basename(filePath);
success(`Task created: ${filename}`);
info(` Path: ${filePath}`);
if (worktree) {

View File

@ -14,7 +14,7 @@ export {
type SelectAndExecuteOptions,
type WorktreeConfirmationResult,
} from './execute/selectAndExecute.js';
export { addTask } from './add/index.js';
export { addTask, saveTaskFile, saveTaskFromInteractive, createIssueFromTask } from './add/index.js';
export { watchTasks } from './watch/index.js';
export {
listTasks,

View File

@ -2,7 +2,7 @@
* GitHub integration - barrel exports
*/
export type { GitHubIssue, GhCliStatus, CreatePrOptions, CreatePrResult } from './types.js';
export type { GitHubIssue, GhCliStatus, CreatePrOptions, CreatePrResult, CreateIssueOptions, CreateIssueResult } from './types.js';
export {
checkGhCli,
@ -11,6 +11,7 @@ export {
parseIssueNumbers,
isIssueReference,
resolveIssueTask,
createIssue,
} from './issue.js';
export { pushBranch, createPullRequest, buildPrBody } from './pr.js';

View File

@ -6,10 +6,10 @@
*/
import { execFileSync } from 'node:child_process';
import { createLogger } from '../../shared/utils/index.js';
import type { GitHubIssue, GhCliStatus } from './types.js';
import { createLogger, getErrorMessage } from '../../shared/utils/index.js';
import type { GitHubIssue, GhCliStatus, CreateIssueOptions, CreateIssueResult } from './types.js';
export type { GitHubIssue, GhCliStatus };
export type { GitHubIssue, GhCliStatus, CreateIssueOptions, CreateIssueResult };
const log = createLogger('github');
@ -172,3 +172,36 @@ export function resolveIssueTask(task: string): string {
const issues = issueNumbers.map((n) => fetchIssue(n));
return issues.map(formatIssueAsTask).join('\n\n---\n\n');
}
/**
* Create a GitHub Issue via `gh issue create`.
*/
export function createIssue(options: CreateIssueOptions): CreateIssueResult {
const ghStatus = checkGhCli();
if (!ghStatus.available) {
return { success: false, error: ghStatus.error };
}
const args = ['issue', 'create', '--title', options.title, '--body', options.body];
if (options.labels && options.labels.length > 0) {
args.push('--label', options.labels.join(','));
}
log.info('Creating issue', { title: options.title });
try {
const output = execFileSync('gh', args, {
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe'],
});
const url = output.trim();
log.info('Issue created', { url });
return { success: true, url };
} catch (err) {
const errorMessage = getErrorMessage(err);
log.error('Issue creation failed', { error: errorMessage });
return { success: false, error: errorMessage };
}
}

View File

@ -35,3 +35,20 @@ export interface CreatePrResult {
/** Error message on failure */
error?: string;
}
export interface CreateIssueOptions {
/** Issue title */
title: string;
/** Issue body (markdown) */
body: string;
/** Labels to apply */
labels?: string[];
}
export interface CreateIssueResult {
success: boolean;
/** Issue URL on success */
url?: string;
/** Error message on failure */
error?: string;
}

View File

@ -16,7 +16,12 @@ interactive:
summarizeFailed: "Failed to summarize conversation. Please try again."
continuePrompt: "Okay, continue describing your task."
proposed: "Proposed task instruction:"
confirm: "Use this task instruction?"
actionPrompt: "What would you like to do?"
actions:
execute: "Execute now"
createIssue: "Create GitHub Issue"
saveTask: "Save as Task"
continue: "Continue editing"
cancelled: "Cancelled"
playNoTask: "Please specify task content: /play <task>"
previousTask:

View File

@ -16,7 +16,12 @@ interactive:
summarizeFailed: "会話の要約に失敗しました。再度お試しください。"
continuePrompt: "続けてタスク内容を入力してください。"
proposed: "提案されたタスク指示:"
confirm: "このタスク指示で進めますか?"
actionPrompt: "どうしますか?"
actions:
execute: "実行する"
createIssue: "GitHub Issueを建てる"
saveTask: "タスクにつむ"
continue: "会話を続ける"
cancelled: "キャンセルしました"
playNoTask: "タスク内容を指定してください: /play <タスク内容>"
previousTask: