From 4e58c866437b28b8c1e4515da6972e36d042d3a6 Mon Sep 17 00:00:00 2001 From: nrs <38722970+nrslib@users.noreply.github.com> Date: Fri, 13 Feb 2026 22:08:28 +0900 Subject: [PATCH] github-issue-256-takt-list-instruct (#267) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix: OpenCode SDKサーバー起動タイムアウトを30秒に延長 * takt: github-issue-256-takt-list-instruct * refactor: 会話後アクションフローを共通化 --- src/__tests__/actionDispatcher.test.ts | 39 +++ src/__tests__/instructMode.test.ts | 282 +++++++++++++++++++ src/app/cli/routing.ts | 43 ++- src/features/interactive/actionDispatcher.ts | 20 ++ src/features/interactive/conversationLoop.ts | 9 +- src/features/interactive/interactive.ts | 85 +++++- src/features/tasks/list/index.ts | 6 + src/features/tasks/list/instructMode.ts | 123 ++++++++ src/features/tasks/list/taskActions.ts | 130 +++++---- src/shared/i18n/labels_en.yaml | 16 ++ src/shared/i18n/labels_ja.yaml | 16 ++ 11 files changed, 686 insertions(+), 83 deletions(-) create mode 100644 src/__tests__/actionDispatcher.test.ts create mode 100644 src/__tests__/instructMode.test.ts create mode 100644 src/features/interactive/actionDispatcher.ts create mode 100644 src/features/tasks/list/instructMode.ts diff --git a/src/__tests__/actionDispatcher.test.ts b/src/__tests__/actionDispatcher.test.ts new file mode 100644 index 0000000..59671e7 --- /dev/null +++ b/src/__tests__/actionDispatcher.test.ts @@ -0,0 +1,39 @@ +import { describe, it, expect, vi } from 'vitest'; + +import { dispatchConversationAction } from '../features/interactive/actionDispatcher.js'; + +describe('dispatchConversationAction', () => { + it('should dispatch to matching handler with full result payload', async () => { + const execute = vi.fn().mockResolvedValue('executed'); + const saveTask = vi.fn().mockResolvedValue('saved'); + const cancel = vi.fn().mockResolvedValue('cancelled'); + + const result = await dispatchConversationAction( + { action: 'save_task', task: 'refine branch docs' }, + { + execute, + save_task: saveTask, + cancel, + }, + ); + + expect(result).toBe('saved'); + expect(saveTask).toHaveBeenCalledWith({ action: 'save_task', task: 'refine branch docs' }); + expect(execute).not.toHaveBeenCalled(); + expect(cancel).not.toHaveBeenCalled(); + }); + + it('should support synchronous handlers', async () => { + const result = await dispatchConversationAction( + { action: 'cancel', task: '' }, + { + execute: () => true, + save_task: () => true, + cancel: () => false, + }, + ); + + expect(result).toBe(false); + }); +}); + diff --git a/src/__tests__/instructMode.test.ts b/src/__tests__/instructMode.test.ts new file mode 100644 index 0000000..b01d1d1 --- /dev/null +++ b/src/__tests__/instructMode.test.ts @@ -0,0 +1,282 @@ +/** + * Tests for instruct mode + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; + +vi.mock('../infra/config/global/globalConfig.js', () => ({ + loadGlobalConfig: vi.fn(() => ({ provider: 'mock', language: 'en' })), + getBuiltinPiecesEnabled: vi.fn().mockReturnValue(true), +})); + +vi.mock('../infra/providers/index.js', () => ({ + getProvider: vi.fn(), +})); + +vi.mock('../shared/utils/index.js', async (importOriginal) => ({ + ...(await importOriginal>()), + createLogger: () => ({ + info: vi.fn(), + debug: vi.fn(), + error: vi.fn(), + }), +})); + +vi.mock('../shared/context.js', () => ({ + isQuietMode: vi.fn(() => false), +})); + +vi.mock('../infra/config/paths.js', async (importOriginal) => ({ + ...(await importOriginal>()), + loadPersonaSessions: vi.fn(() => ({})), + updatePersonaSession: vi.fn(), + getProjectConfigDir: vi.fn(() => '/tmp'), + loadSessionState: vi.fn(() => null), + clearSessionState: vi.fn(), +})); + +vi.mock('../shared/ui/index.js', () => ({ + info: vi.fn(), + error: vi.fn(), + blankLine: vi.fn(), + StreamDisplay: vi.fn().mockImplementation(() => ({ + createHandler: vi.fn(() => vi.fn()), + flush: vi.fn(), + })), +})); + +vi.mock('../shared/prompt/index.js', () => ({ + selectOption: vi.fn(), +})); + +vi.mock('../shared/i18n/index.js', () => ({ + getLabel: vi.fn((_key: string, _lang: string) => 'Mock label'), + getLabelObject: vi.fn(() => ({ + intro: 'Instruct mode intro', + resume: 'Resuming', + noConversation: 'No conversation', + summarizeFailed: 'Summarize failed', + continuePrompt: 'Continue', + proposed: 'Proposed task:', + actionPrompt: 'What to do?', + actions: { + execute: 'Execute', + saveTask: 'Save task', + continue: 'Continue', + }, + cancelled: 'Cancelled', + })), +})); + +vi.mock('../shared/prompts/index.js', () => ({ + loadTemplate: vi.fn((_name: string, _lang: string) => 'Mock template content'), +})); + +import { getProvider } from '../infra/providers/index.js'; +import { runInstructMode } from '../features/tasks/list/instructMode.js'; +import { selectOption } from '../shared/prompt/index.js'; +import { info } from '../shared/ui/index.js'; + +const mockGetProvider = vi.mocked(getProvider); +const mockSelectOption = vi.mocked(selectOption); +const mockInfo = vi.mocked(info); + +let savedIsTTY: boolean | undefined; +let savedIsRaw: boolean | undefined; +let savedSetRawMode: typeof process.stdin.setRawMode | undefined; +let savedStdoutWrite: typeof process.stdout.write; +let savedStdinOn: typeof process.stdin.on; +let savedStdinRemoveListener: typeof process.stdin.removeListener; +let savedStdinResume: typeof process.stdin.resume; +let savedStdinPause: typeof process.stdin.pause; + +function setupRawStdin(rawInputs: string[]): void { + savedIsTTY = process.stdin.isTTY; + savedIsRaw = process.stdin.isRaw; + savedSetRawMode = process.stdin.setRawMode; + savedStdoutWrite = process.stdout.write; + savedStdinOn = process.stdin.on; + savedStdinRemoveListener = process.stdin.removeListener; + savedStdinResume = process.stdin.resume; + savedStdinPause = process.stdin.pause; + + Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true }); + Object.defineProperty(process.stdin, 'isRaw', { value: false, configurable: true, writable: true }); + process.stdin.setRawMode = vi.fn((mode: boolean) => { + (process.stdin as unknown as { isRaw: boolean }).isRaw = mode; + return process.stdin; + }) as unknown as typeof process.stdin.setRawMode; + process.stdout.write = vi.fn(() => true) as unknown as typeof process.stdout.write; + process.stdin.resume = vi.fn(() => process.stdin) as unknown as typeof process.stdin.resume; + process.stdin.pause = vi.fn(() => process.stdin) as unknown as typeof process.stdin.pause; + + let currentHandler: ((data: Buffer) => void) | null = null; + let inputIndex = 0; + + process.stdin.on = vi.fn(((event: string, handler: (...args: unknown[]) => void) => { + if (event === 'data') { + currentHandler = handler as (data: Buffer) => void; + if (inputIndex < rawInputs.length) { + const data = rawInputs[inputIndex]!; + inputIndex++; + queueMicrotask(() => { + if (currentHandler) { + currentHandler(Buffer.from(data, 'utf-8')); + } + }); + } + } + return process.stdin; + }) as typeof process.stdin.on); + + process.stdin.removeListener = vi.fn(((event: string) => { + if (event === 'data') { + currentHandler = null; + } + return process.stdin; + }) as typeof process.stdin.removeListener); +} + +function restoreStdin(): void { + if (savedIsTTY !== undefined) { + Object.defineProperty(process.stdin, 'isTTY', { value: savedIsTTY, configurable: true }); + } + if (savedIsRaw !== undefined) { + Object.defineProperty(process.stdin, 'isRaw', { value: savedIsRaw, configurable: true, writable: true }); + } + if (savedSetRawMode) { + process.stdin.setRawMode = savedSetRawMode; + } + if (savedStdoutWrite) { + process.stdout.write = savedStdoutWrite; + } + if (savedStdinOn) { + process.stdin.on = savedStdinOn; + } + if (savedStdinRemoveListener) { + process.stdin.removeListener = savedStdinRemoveListener; + } + if (savedStdinResume) { + process.stdin.resume = savedStdinResume; + } + if (savedStdinPause) { + process.stdin.pause = savedStdinPause; + } +} + +function toRawInputs(inputs: (string | null)[]): string[] { + return inputs.map((input) => { + if (input === null) return '\x04'; + return input + '\r'; + }); +} + +function setupMockProvider(responses: string[]): void { + let callIndex = 0; + const mockCall = vi.fn(async () => { + const content = callIndex < responses.length ? responses[callIndex] : 'AI response'; + callIndex++; + return { + persona: 'instruct', + status: 'done' as const, + content: content!, + timestamp: new Date(), + }; + }); + const mockProvider = { + setup: () => ({ call: mockCall }), + _call: mockCall, + }; + mockGetProvider.mockReturnValue(mockProvider); +} + +beforeEach(() => { + vi.clearAllMocks(); + mockSelectOption.mockResolvedValue('execute'); +}); + +afterEach(() => { + restoreStdin(); +}); + +describe('runInstructMode', () => { + it('should return action=cancel when user types /cancel', async () => { + setupRawStdin(toRawInputs(['/cancel'])); + setupMockProvider([]); + + const result = await runInstructMode('/project', 'branch context', 'feature-branch'); + + expect(result.action).toBe('cancel'); + expect(result.task).toBe(''); + }); + + it('should include branch name in intro message', async () => { + setupRawStdin(toRawInputs(['/cancel'])); + setupMockProvider([]); + + await runInstructMode('/project', 'diff stats', 'my-feature-branch'); + + const introCall = mockInfo.mock.calls.find((call) => + call[0]?.includes('my-feature-branch') + ); + expect(introCall).toBeDefined(); + }); + + it('should return action=execute with task on /go after conversation', async () => { + setupRawStdin(toRawInputs(['add more tests', '/go'])); + setupMockProvider(['What kind of tests?', 'Add unit tests for the feature.']); + + const result = await runInstructMode('/project', 'branch context', 'feature-branch'); + + expect(result.action).toBe('execute'); + expect(result.task).toBe('Add unit tests for the feature.'); + }); + + it('should return action=save_task when user selects save task', async () => { + setupRawStdin(toRawInputs(['describe task', '/go'])); + setupMockProvider(['response', 'Summarized task.']); + mockSelectOption.mockResolvedValue('save_task'); + + const result = await runInstructMode('/project', 'branch context', 'feature-branch'); + + expect(result.action).toBe('save_task'); + expect(result.task).toBe('Summarized task.'); + }); + + it('should continue editing when user selects continue', async () => { + setupRawStdin(toRawInputs(['describe task', '/go', '/cancel'])); + setupMockProvider(['response', 'Summarized task.']); + mockSelectOption.mockResolvedValueOnce('continue'); + + const result = await runInstructMode('/project', 'branch context', 'feature-branch'); + + expect(result.action).toBe('cancel'); + }); + + it('should reject /go with no prior conversation', async () => { + setupRawStdin(toRawInputs(['/go', '/cancel'])); + setupMockProvider([]); + + const result = await runInstructMode('/project', 'branch context', 'feature-branch'); + + expect(result.action).toBe('cancel'); + }); + + it('should use custom action selector without create_issue option', async () => { + setupRawStdin(toRawInputs(['task', '/go'])); + setupMockProvider(['response', 'Task summary.']); + + await runInstructMode('/project', 'branch context', 'feature-branch'); + + const selectCall = mockSelectOption.mock.calls.find((call) => + Array.isArray(call[1]) + ); + expect(selectCall).toBeDefined(); + const options = selectCall![1] as Array<{ value: string }>; + const values = options.map((o) => o.value); + expect(values).toContain('execute'); + expect(values).toContain('save_task'); + expect(values).toContain('continue'); + expect(values).not.toContain('create_issue'); + }); +}); diff --git a/src/app/cli/routing.ts b/src/app/cli/routing.ts index 140b16e..d4626a2 100644 --- a/src/app/cli/routing.ts +++ b/src/app/cli/routing.ts @@ -22,6 +22,7 @@ import { resolveLanguage, type InteractiveModeResult, } from '../../features/interactive/index.js'; +import { dispatchConversationAction } from '../../features/interactive/actionDispatcher.js'; import { getPieceDescription, loadGlobalConfig } from '../../infra/config/index.js'; import { DEFAULT_PIECE_NAME } from '../../shared/constants.js'; import { program, resolvedCwd, pipelineMode } from './program.js'; @@ -202,33 +203,27 @@ export async function executeDefaultAction(task?: string): Promise { } } - switch (result.action) { - case 'execute': + await dispatchConversationAction(result, { + execute: async ({ task: confirmedTask }) => { selectOptions.interactiveUserInput = true; selectOptions.piece = pieceId; - selectOptions.interactiveMetadata = { confirmed: true, task: result.task }; - await selectAndExecuteTask(resolvedCwd, result.task, selectOptions, agentOverrides); - break; - - case 'create_issue': - { - const issueNumber = createIssueFromTask(result.task); - if (issueNumber !== undefined) { - await saveTaskFromInteractive(resolvedCwd, result.task, pieceId, { - issue: issueNumber, - confirmAtEndMessage: 'Add this issue to tasks?', - }); - } + selectOptions.interactiveMetadata = { confirmed: true, task: confirmedTask }; + await selectAndExecuteTask(resolvedCwd, confirmedTask, selectOptions, agentOverrides); + }, + create_issue: async ({ task: confirmedTask }) => { + const issueNumber = createIssueFromTask(confirmedTask); + if (issueNumber !== undefined) { + await saveTaskFromInteractive(resolvedCwd, confirmedTask, pieceId, { + issue: issueNumber, + confirmAtEndMessage: 'Add this issue to tasks?', + }); } - break; - - case 'save_task': - await saveTaskFromInteractive(resolvedCwd, result.task, pieceId); - break; - - case 'cancel': - break; - } + }, + save_task: async ({ task: confirmedTask }) => { + await saveTaskFromInteractive(resolvedCwd, confirmedTask, pieceId); + }, + cancel: () => undefined, + }); } program diff --git a/src/features/interactive/actionDispatcher.ts b/src/features/interactive/actionDispatcher.ts new file mode 100644 index 0000000..4b5d23a --- /dev/null +++ b/src/features/interactive/actionDispatcher.ts @@ -0,0 +1,20 @@ +/** + * Shared dispatcher for post-conversation actions. + */ + +export interface ConversationActionResult { + action: A; + task: string; +} + +export type ConversationActionHandler = ( + result: ConversationActionResult, +) => Promise | R; + +export async function dispatchConversationAction( + result: ConversationActionResult, + handlers: Record>, +): Promise { + return handlers[result.action](result); +} + diff --git a/src/features/interactive/conversationLoop.ts b/src/features/interactive/conversationLoop.ts index 156862f..fb988ef 100644 --- a/src/features/interactive/conversationLoop.ts +++ b/src/features/interactive/conversationLoop.ts @@ -28,6 +28,7 @@ import { type InteractiveModeResult, type InteractiveUIText, type ConversationMessage, + type PostSummaryAction, resolveLanguage, buildSummaryPrompt, selectPostSummaryAction, @@ -171,6 +172,8 @@ export async function callAIWithRetry( } } +export type { PostSummaryAction } from './interactive.js'; + /** Strategy for customizing conversation loop behavior */ export interface ConversationStrategy { /** System prompt for AI calls */ @@ -181,6 +184,8 @@ export interface ConversationStrategy { transformPrompt: (userMessage: string) => string; /** Intro message displayed at start */ introMessage: string; + /** Custom action selector (optional). If not provided, uses default selectPostSummaryAction. */ + selectAction?: (task: string, lang: 'en' | 'ja') => Promise; } /** @@ -284,7 +289,9 @@ export async function runConversationLoop( return { action: 'cancel', task: '' }; } const task = summaryResult.content.trim(); - const selectedAction = await selectPostSummaryAction(task, ui.proposed, ui); + const selectedAction = strategy.selectAction + ? await strategy.selectAction(task, ctx.lang) + : await selectPostSummaryAction(task, ui.proposed, ui); if (selectedAction === 'continue' || selectedAction === null) { info(ui.continuePrompt); continue; diff --git a/src/features/interactive/interactive.ts b/src/features/interactive/interactive.ts index 36b9104..bc31775 100644 --- a/src/features/interactive/interactive.ts +++ b/src/features/interactive/interactive.ts @@ -169,21 +169,90 @@ export function buildSummaryPrompt( export type PostSummaryAction = InteractiveModeAction | 'continue'; -export async function selectPostSummaryAction( +export type SummaryActionValue = 'execute' | 'create_issue' | 'save_task' | 'continue'; + +export interface SummaryActionOption { + label: string; + value: SummaryActionValue; +} + +export type SummaryActionLabels = { + execute: string; + createIssue?: string; + saveTask: string; + continue: string; +}; + +export const BASE_SUMMARY_ACTIONS: readonly SummaryActionValue[] = [ + 'execute', + 'save_task', + 'continue', +]; + +export function buildSummaryActionOptions( + labels: SummaryActionLabels, + append: readonly SummaryActionValue[] = [], +): SummaryActionOption[] { + const order = [...BASE_SUMMARY_ACTIONS, ...append]; + const seen = new Set(); + const options: SummaryActionOption[] = []; + + for (const action of order) { + if (seen.has(action)) continue; + seen.add(action); + + if (action === 'execute') { + options.push({ label: labels.execute, value: action }); + continue; + } + if (action === 'create_issue') { + if (labels.createIssue) { + options.push({ label: labels.createIssue, value: action }); + } + continue; + } + if (action === 'save_task') { + options.push({ label: labels.saveTask, value: action }); + continue; + } + options.push({ label: labels.continue, value: action }); + } + + return options; +} + +export async function selectSummaryAction( task: string, proposedLabel: string, - ui: InteractiveUIText, + actionPrompt: string, + options: SummaryActionOption[], ): Promise { blankLine(); info(proposedLabel); console.log(task); - return selectOption(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 selectOption(actionPrompt, options); +} + +export async function selectPostSummaryAction( + task: string, + proposedLabel: string, + ui: InteractiveUIText, +): Promise { + return selectSummaryAction( + task, + proposedLabel, + ui.actionPrompt, + buildSummaryActionOptions( + { + execute: ui.actions.execute, + createIssue: ui.actions.createIssue, + saveTask: ui.actions.saveTask, + continue: ui.actions.continue, + }, + ['create_issue'], + ), + ); } export type InteractiveModeAction = 'execute' | 'save_task' | 'create_issue' | 'cancel'; diff --git a/src/features/tasks/list/index.ts b/src/features/tasks/list/index.ts index 4d26732..287962b 100644 --- a/src/features/tasks/list/index.ts +++ b/src/features/tasks/list/index.ts @@ -44,6 +44,12 @@ export { instructBranch, } from './taskActions.js'; +export { + type InstructModeAction, + type InstructModeResult, + runInstructMode, +} from './instructMode.js'; + /** Task action type for pending task action selection menu */ type PendingTaskAction = 'delete'; diff --git a/src/features/tasks/list/instructMode.ts b/src/features/tasks/list/instructMode.ts new file mode 100644 index 0000000..9d83096 --- /dev/null +++ b/src/features/tasks/list/instructMode.ts @@ -0,0 +1,123 @@ +/** + * Instruct mode for branch-based tasks. + * + * Provides conversation loop for additional instructions on existing branches, + * similar to interactive mode but with branch context and limited actions. + */ + +import { + initializeSession, + displayAndClearSessionState, + runConversationLoop, + type SessionContext, + type ConversationStrategy, + type PostSummaryAction, +} from '../../interactive/conversationLoop.js'; +import { + resolveLanguage, + buildSummaryActionOptions, + selectSummaryAction, +} from '../../interactive/interactive.js'; +import { loadTemplate } from '../../../shared/prompts/index.js'; +import { getLabelObject } from '../../../shared/i18n/index.js'; +import { loadGlobalConfig } from '../../../infra/config/index.js'; + +export type InstructModeAction = 'execute' | 'save_task' | 'cancel'; + +export interface InstructModeResult { + action: InstructModeAction; + task: string; +} + +export interface InstructUIText { + intro: string; + resume: string; + noConversation: string; + summarizeFailed: string; + continuePrompt: string; + proposed: string; + actionPrompt: string; + actions: { + execute: string; + saveTask: string; + continue: string; + }; + cancelled: string; +} + +const INSTRUCT_TOOLS = ['Read', 'Glob', 'Grep', 'Bash', 'WebSearch', 'WebFetch']; + +function createSelectInstructAction(ui: InstructUIText): (task: string, lang: 'en' | 'ja') => Promise { + return async (task: string, _lang: 'en' | 'ja'): Promise => { + return selectSummaryAction( + task, + ui.proposed, + ui.actionPrompt, + buildSummaryActionOptions({ + execute: ui.actions.execute, + saveTask: ui.actions.saveTask, + continue: ui.actions.continue, + }), + ); + }; +} + +export async function runInstructMode( + cwd: string, + branchContext: string, + branchName: string, +): Promise { + const globalConfig = loadGlobalConfig(); + const lang = resolveLanguage(globalConfig.language); + + if (!globalConfig.provider) { + throw new Error('Provider is not configured.'); + } + + const baseCtx = initializeSession(cwd, 'instruct'); + const ctx: SessionContext = { ...baseCtx, lang, personaName: 'instruct' }; + + displayAndClearSessionState(cwd, ctx.lang); + + const ui = getLabelObject('instruct.ui', ctx.lang); + + const systemPrompt = loadTemplate('score_interactive_system_prompt', ctx.lang, { + hasPiecePreview: false, + pieceStructure: '', + movementDetails: '', + }); + + const branchIntro = ctx.lang === 'ja' + ? `## ブランチ: ${branchName}\n\n${branchContext}` + : `## Branch: ${branchName}\n\n${branchContext}`; + + const introMessage = `${branchIntro}\n\n${ui.intro}`; + + const policyContent = loadTemplate('score_interactive_policy', ctx.lang, {}); + + function injectPolicy(userMessage: string): string { + const policyIntro = ctx.lang === 'ja' + ? '以下のポリシーは行動規範です。必ず遵守してください。' + : 'The following policy defines behavioral guidelines. Please follow them.'; + const reminderLabel = ctx.lang === 'ja' + ? '上記の Policy セクションで定義されたポリシー規範を遵守してください。' + : 'Please follow the policy guidelines defined in the Policy section above.'; + return `## Policy\n${policyIntro}\n\n${policyContent}\n\n---\n\n${userMessage}\n\n---\n**Policy Reminder:** ${reminderLabel}`; + } + + const strategy: ConversationStrategy = { + systemPrompt, + allowedTools: INSTRUCT_TOOLS, + transformPrompt: injectPolicy, + introMessage, + selectAction: createSelectInstructAction(ui), + }; + + const result = await runConversationLoop(cwd, ctx, strategy, undefined, undefined); + + if (result.action === 'cancel') { + return { action: 'cancel', task: '' }; + } + + return { action: result.action as InstructModeAction, task: result.task }; +} diff --git a/src/features/tasks/list/taskActions.ts b/src/features/tasks/list/taskActions.ts index 8321c0b..d062c88 100644 --- a/src/features/tasks/list/taskActions.ts +++ b/src/features/tasks/list/taskActions.ts @@ -19,13 +19,16 @@ import { autoCommitAndPush, type BranchListItem, } from '../../../infra/task/index.js'; -import { selectOption, promptInput } from '../../../shared/prompt/index.js'; +import { selectOption } from '../../../shared/prompt/index.js'; import { info, success, error as logError, warn, header, blankLine } from '../../../shared/ui/index.js'; import { createLogger, getErrorMessage } from '../../../shared/utils/index.js'; import { executeTask } from '../execute/taskExecution.js'; import type { TaskExecutionOptions } from '../execute/types.js'; import { encodeWorktreePath } from '../../../infra/config/project/sessionStore.js'; +import { runInstructMode } from './instructMode.js'; +import { saveTaskFile } from '../add/index.js'; import { selectPiece } from '../../pieceSelection/index.js'; +import { dispatchConversationAction } from '../../interactive/actionDispatcher.js'; const log = createLogger('list-tasks'); @@ -302,8 +305,8 @@ function getBranchContext(projectDir: string, branch: string): string { } /** - * Instruct branch: create a temp clone, give additional instructions, - * auto-commit+push, then remove clone. + * Instruct branch: create a temp clone, give additional instructions via + * interactive conversation, then auto-commit+push or save as task file. */ export async function instructBranch( projectDir: string, @@ -312,54 +315,81 @@ export async function instructBranch( ): Promise { const { branch } = item.info; - const instruction = await promptInput('Enter instruction'); - if (!instruction) { - info('Cancelled'); - return false; - } + const branchContext = getBranchContext(projectDir, branch); + const result = await runInstructMode(projectDir, branchContext, branch); + let selectedPiece: string | null = null; - const selectedPiece = await selectPiece(projectDir); - if (!selectedPiece) { - info('Cancelled'); - return false; - } - - log.info('Instructing branch via temp clone', { branch, piece: selectedPiece }); - info(`Running instruction on ${branch}...`); - - const clone = createTempCloneForBranch(projectDir, branch); - - try { - const branchContext = getBranchContext(projectDir, branch); - const fullInstruction = branchContext - ? `${branchContext}## 追加指示\n${instruction}` - : instruction; - - const taskSuccess = await executeTask({ - task: fullInstruction, - cwd: clone.path, - pieceIdentifier: selectedPiece, - projectCwd: projectDir, - agentOverrides: options, - }); - - if (taskSuccess) { - const commitResult = autoCommitAndPush(clone.path, item.taskSlug, projectDir); - if (commitResult.success && commitResult.commitHash) { - info(`Auto-committed & pushed: ${commitResult.commitHash}`); - } else if (!commitResult.success) { - warn(`Auto-commit skipped: ${commitResult.message}`); - } - success(`Instruction completed on ${branch}`); - log.info('Instruction completed', { branch }); - } else { - logError(`Instruction failed on ${branch}`); - log.error('Instruction failed', { branch }); + const ensurePieceSelected = async (): Promise => { + if (selectedPiece) { + return selectedPiece; } + selectedPiece = await selectPiece(projectDir); + if (!selectedPiece) { + info('Cancelled'); + return null; + } + return selectedPiece; + }; - return taskSuccess; - } finally { - removeClone(clone.path); - removeCloneMeta(projectDir, branch); - } + return dispatchConversationAction(result, { + cancel: () => { + info('Cancelled'); + return false; + }, + save_task: async ({ task }) => { + const piece = await ensurePieceSelected(); + if (!piece) { + return false; + } + const created = await saveTaskFile(projectDir, task, { piece }); + success(`Task saved: ${created.taskName}`); + info(` File: ${created.tasksFile}`); + log.info('Task saved from instruct mode', { branch, piece }); + return true; + }, + execute: async ({ task }) => { + const piece = await ensurePieceSelected(); + if (!piece) { + return false; + } + + log.info('Instructing branch via temp clone', { branch, piece }); + info(`Running instruction on ${branch}...`); + + const clone = createTempCloneForBranch(projectDir, branch); + + try { + const fullInstruction = branchContext + ? `${branchContext}## 追加指示\n${task}` + : task; + + const taskSuccess = await executeTask({ + task: fullInstruction, + cwd: clone.path, + pieceIdentifier: piece, + projectCwd: projectDir, + agentOverrides: options, + }); + + if (taskSuccess) { + const commitResult = autoCommitAndPush(clone.path, item.taskSlug, projectDir); + if (commitResult.success && commitResult.commitHash) { + info(`Auto-committed & pushed: ${commitResult.commitHash}`); + } else if (!commitResult.success) { + warn(`Auto-commit skipped: ${commitResult.message}`); + } + success(`Instruction completed on ${branch}`); + log.info('Instruction completed', { branch }); + } else { + logError(`Instruction failed on ${branch}`); + log.error('Instruction failed', { branch }); + } + + return taskSuccess; + } finally { + removeClone(clone.path); + removeCloneMeta(projectDir, branch); + } + }, + }); } diff --git a/src/shared/i18n/labels_en.yaml b/src/shared/i18n/labels_en.yaml index 0d76f2f..607eafa 100644 --- a/src/shared/i18n/labels_en.yaml +++ b/src/shared/i18n/labels_en.yaml @@ -68,6 +68,22 @@ piece: sigintTimeout: "Graceful shutdown timed out after {timeoutMs}ms" sigintForce: "Ctrl+C: Force exit" +# ===== Instruct Mode UI (takt list -> instruct) ===== +instruct: + ui: + intro: "Instruct mode - describe additional instructions. Commands: /go (summarize), /cancel (exit)" + resume: "Resuming previous session" + noConversation: "No conversation yet. Please describe your instructions first." + summarizeFailed: "Failed to summarize conversation. Please try again." + continuePrompt: "Okay, continue describing your instructions." + proposed: "Proposed additional instructions:" + actionPrompt: "What would you like to do?" + actions: + execute: "Execute now" + saveTask: "Save as Task" + continue: "Continue editing" + cancelled: "Cancelled" + run: notifyComplete: "Run complete ({total} tasks)" notifyAbort: "Run finished with errors ({failed})" diff --git a/src/shared/i18n/labels_ja.yaml b/src/shared/i18n/labels_ja.yaml index 2bc9ed0..eac4cd8 100644 --- a/src/shared/i18n/labels_ja.yaml +++ b/src/shared/i18n/labels_ja.yaml @@ -68,6 +68,22 @@ piece: sigintTimeout: "graceful停止がタイムアウトしました ({timeoutMs}ms)" sigintForce: "Ctrl+C: 強制終了します" +# ===== Instruct Mode UI (takt list -> instruct) ===== +instruct: + ui: + intro: "指示モード - 追加指示を入力してください。コマンド: /go(要約), /cancel(終了)" + resume: "前回のセッションを再開します" + noConversation: "まだ会話がありません。まず追加指示を入力してください。" + summarizeFailed: "会話の要約に失敗しました。再度お試しください。" + continuePrompt: "続けて追加指示を入力してください。" + proposed: "提案された追加指示:" + actionPrompt: "どうしますか?" + actions: + execute: "実行する" + saveTask: "タスクにつむ" + continue: "会話を続ける" + cancelled: "キャンセルしました" + run: notifyComplete: "run完了 ({total} tasks)" notifyAbort: "runはエラー終了 ({failed})"