diff --git a/src/__tests__/StreamDisplay.test.ts b/src/__tests__/StreamDisplay.test.ts new file mode 100644 index 0000000..42bad7f --- /dev/null +++ b/src/__tests__/StreamDisplay.test.ts @@ -0,0 +1,177 @@ +/** + * Tests for StreamDisplay progress info feature + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { StreamDisplay, type ProgressInfo } from '../shared/ui/index.js'; + +describe('StreamDisplay', () => { + let consoleLogSpy: ReturnType; + let stdoutWriteSpy: ReturnType; + + beforeEach(() => { + consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(() => {}); + stdoutWriteSpy = vi.spyOn(process.stdout, 'write').mockImplementation(() => true); + }); + + afterEach(() => { + consoleLogSpy.mockRestore(); + stdoutWriteSpy.mockRestore(); + }); + + describe('progress info display', () => { + const progressInfo: ProgressInfo = { + iteration: 3, + maxIterations: 10, + movementIndex: 1, + totalMovements: 4, + }; + + describe('showInit', () => { + it('should include progress info when provided', () => { + const display = new StreamDisplay('test-agent', false, progressInfo); + display.showInit('claude-3'); + + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringContaining('[test-agent]') + ); + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringContaining('(3/10) step 2/4') + ); + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringContaining('Model: claude-3') + ); + }); + + it('should not include progress info when not provided', () => { + const display = new StreamDisplay('test-agent', false); + display.showInit('claude-3'); + + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringContaining('[test-agent]') + ); + expect(consoleLogSpy).toHaveBeenCalledWith( + expect.stringContaining('Model: claude-3') + ); + // Should not contain progress format + expect(consoleLogSpy).not.toHaveBeenCalledWith( + expect.stringMatching(/\(\d+\/\d+\) step \d+\/\d+/) + ); + }); + + it('should not display anything in quiet mode', () => { + const display = new StreamDisplay('test-agent', true, progressInfo); + display.showInit('claude-3'); + + expect(consoleLogSpy).not.toHaveBeenCalled(); + }); + }); + + describe('showText', () => { + it('should include progress info in first text header when provided', () => { + const display = new StreamDisplay('test-agent', false, progressInfo); + display.showText('Hello'); + + // First call is blank line, second is the header + expect(consoleLogSpy).toHaveBeenCalledTimes(2); + expect(consoleLogSpy).toHaveBeenNthCalledWith(2, + expect.stringContaining('[test-agent]') + ); + expect(consoleLogSpy).toHaveBeenNthCalledWith(2, + expect.stringContaining('(3/10) step 2/4') + ); + }); + + it('should not include progress info in header when not provided', () => { + const display = new StreamDisplay('test-agent', false); + display.showText('Hello'); + + expect(consoleLogSpy).toHaveBeenCalledTimes(2); + const headerCall = consoleLogSpy.mock.calls[1]?.[0] as string; + expect(headerCall).toContain('[test-agent]'); + expect(headerCall).not.toMatch(/\(\d+\/\d+\) step \d+\/\d+/); + }); + + it('should output text content to stdout', () => { + const display = new StreamDisplay('test-agent', false, progressInfo); + display.showText('Hello'); + + expect(stdoutWriteSpy).toHaveBeenCalledWith('Hello'); + }); + + it('should not display anything in quiet mode', () => { + const display = new StreamDisplay('test-agent', true, progressInfo); + display.showText('Hello'); + + expect(consoleLogSpy).not.toHaveBeenCalled(); + expect(stdoutWriteSpy).not.toHaveBeenCalled(); + }); + }); + + describe('showThinking', () => { + it('should include progress info in thinking header when provided', () => { + const display = new StreamDisplay('test-agent', false, progressInfo); + display.showThinking('Thinking...'); + + expect(consoleLogSpy).toHaveBeenCalledTimes(2); + expect(consoleLogSpy).toHaveBeenNthCalledWith(2, + expect.stringContaining('[test-agent]') + ); + expect(consoleLogSpy).toHaveBeenNthCalledWith(2, + expect.stringContaining('(3/10) step 2/4') + ); + expect(consoleLogSpy).toHaveBeenNthCalledWith(2, + expect.stringContaining('thinking') + ); + }); + + it('should not include progress info in header when not provided', () => { + const display = new StreamDisplay('test-agent', false); + display.showThinking('Thinking...'); + + expect(consoleLogSpy).toHaveBeenCalledTimes(2); + const headerCall = consoleLogSpy.mock.calls[1]?.[0] as string; + expect(headerCall).toContain('[test-agent]'); + expect(headerCall).not.toMatch(/\(\d+\/\d+\) step \d+\/\d+/); + }); + + it('should not display anything in quiet mode', () => { + const display = new StreamDisplay('test-agent', true, progressInfo); + display.showThinking('Thinking...'); + + expect(consoleLogSpy).not.toHaveBeenCalled(); + expect(stdoutWriteSpy).not.toHaveBeenCalled(); + }); + }); + }); + + describe('progress prefix format', () => { + it('should format progress as (iteration/max) step index/total', () => { + const progressInfo: ProgressInfo = { + iteration: 5, + maxIterations: 20, + movementIndex: 2, + totalMovements: 6, + }; + const display = new StreamDisplay('agent', false, progressInfo); + display.showText('test'); + + const headerCall = consoleLogSpy.mock.calls[1]?.[0] as string; + expect(headerCall).toContain('(5/20) step 3/6'); + }); + + it('should convert 0-indexed movementIndex to 1-indexed display', () => { + const progressInfo: ProgressInfo = { + iteration: 1, + maxIterations: 10, + movementIndex: 0, // First movement (0-indexed) + totalMovements: 4, + }; + const display = new StreamDisplay('agent', false, progressInfo); + display.showText('test'); + + const headerCall = consoleLogSpy.mock.calls[1]?.[0] as string; + expect(headerCall).toContain('step 1/4'); // Should display as 1-indexed + }); + }); +}); diff --git a/src/__tests__/globalConfig-defaults.test.ts b/src/__tests__/globalConfig-defaults.test.ts index 9854205..e120d0e 100644 --- a/src/__tests__/globalConfig-defaults.test.ts +++ b/src/__tests__/globalConfig-defaults.test.ts @@ -121,49 +121,6 @@ describe('loadGlobalConfig', () => { expect(reloaded.pipeline!.commitMessageTemplate).toBe('feat: {title} (#{issue})'); }); - it('should load auto_pr config from config.yaml', () => { - const taktDir = join(testHomeDir, '.takt'); - mkdirSync(taktDir, { recursive: true }); - writeFileSync( - getGlobalConfigPath(), - 'language: en\nauto_pr: true\n', - 'utf-8', - ); - - const config = loadGlobalConfig(); - - expect(config.autoPr).toBe(true); - }); - - it('should save and reload auto_pr config', () => { - const taktDir = join(testHomeDir, '.takt'); - mkdirSync(taktDir, { recursive: true }); - // Create minimal config first - writeFileSync(getGlobalConfigPath(), 'language: en\n', 'utf-8'); - - const config = loadGlobalConfig(); - config.autoPr = true; - saveGlobalConfig(config); - invalidateGlobalConfigCache(); - - const reloaded = loadGlobalConfig(); - expect(reloaded.autoPr).toBe(true); - }); - - it('should save auto_pr: false when explicitly set', () => { - const taktDir = join(testHomeDir, '.takt'); - mkdirSync(taktDir, { recursive: true }); - writeFileSync(getGlobalConfigPath(), 'language: en\n', 'utf-8'); - - const config = loadGlobalConfig(); - config.autoPr = false; - saveGlobalConfig(config); - invalidateGlobalConfigCache(); - - const reloaded = loadGlobalConfig(); - expect(reloaded.autoPr).toBe(false); - }); - it('should read from cache without hitting disk on second call', () => { const taktDir = join(testHomeDir, '.takt'); mkdirSync(taktDir, { recursive: true }); diff --git a/src/__tests__/parallel-logger.test.ts b/src/__tests__/parallel-logger.test.ts index 1d15c30..5f41b50 100644 --- a/src/__tests__/parallel-logger.test.ts +++ b/src/__tests__/parallel-logger.test.ts @@ -414,4 +414,72 @@ describe('ParallelLogger', () => { expect(output[2]).toContain('A second'); }); }); + + describe('progress info display', () => { + it('should include progress info in prefix when provided', () => { + const logger = new ParallelLogger({ + subMovementNames: ['step-a', 'step-b'], + writeFn, + progressInfo: { + iteration: 3, + maxIterations: 10, + }, + }); + + const prefix = logger.buildPrefix('step-a', 0); + expect(prefix).toContain('[step-a]'); + expect(prefix).toContain('(3/10)'); + expect(prefix).toContain('step 1/2'); // 0-indexed -> 1-indexed, 2 total sub-movements + }); + + it('should show correct step number for each sub-movement', () => { + const logger = new ParallelLogger({ + subMovementNames: ['step-a', 'step-b', 'step-c'], + writeFn, + progressInfo: { + iteration: 5, + maxIterations: 20, + }, + }); + + const prefixA = logger.buildPrefix('step-a', 0); + const prefixB = logger.buildPrefix('step-b', 1); + const prefixC = logger.buildPrefix('step-c', 2); + + expect(prefixA).toContain('step 1/3'); + expect(prefixB).toContain('step 2/3'); + expect(prefixC).toContain('step 3/3'); + }); + + it('should not include progress info when not provided', () => { + const logger = new ParallelLogger({ + subMovementNames: ['step-a'], + writeFn, + }); + + const prefix = logger.buildPrefix('step-a', 0); + expect(prefix).toContain('[step-a]'); + expect(prefix).not.toMatch(/\(\d+\/\d+\)/); + expect(prefix).not.toMatch(/step \d+\/\d+/); + }); + + it('should include progress info in streamed output', () => { + const logger = new ParallelLogger({ + subMovementNames: ['step-a'], + writeFn, + progressInfo: { + iteration: 2, + maxIterations: 5, + }, + }); + const handler = logger.createStreamHandler('step-a', 0); + + handler({ type: 'text', data: { text: 'Hello world\n' } } as StreamEvent); + + expect(output).toHaveLength(1); + expect(output[0]).toContain('[step-a]'); + expect(output[0]).toContain('(2/5) step 1/1'); + expect(output[0]).toContain('Hello world'); + }); + }); }); diff --git a/src/__tests__/saveTaskFile.test.ts b/src/__tests__/saveTaskFile.test.ts index 5111656..6c087ad 100644 --- a/src/__tests__/saveTaskFile.test.ts +++ b/src/__tests__/saveTaskFile.test.ts @@ -121,25 +121,6 @@ describe('saveTaskFile', () => { expect(content).not.toContain('issue:'); expect(content).not.toContain('worktree:'); expect(content).not.toContain('branch:'); - expect(content).not.toContain('auto_pr:'); - }); - - it('should include auto_pr in YAML when specified', async () => { - // When - const filePath = await saveTaskFile(testDir, 'Task', { autoPr: true }); - - // Then - const content = fs.readFileSync(filePath, 'utf-8'); - expect(content).toContain('auto_pr: true'); - }); - - it('should include auto_pr: false in YAML when specified as false', async () => { - // When - const filePath = await saveTaskFile(testDir, 'Task', { autoPr: false }); - - // Then - const content = fs.readFileSync(filePath, 'utf-8'); - expect(content).toContain('auto_pr: false'); }); it('should use first line for filename generation', async () => { diff --git a/src/__tests__/taskExecution.test.ts b/src/__tests__/taskExecution.test.ts index 89212c7..735cb38 100644 --- a/src/__tests__/taskExecution.test.ts +++ b/src/__tests__/taskExecution.test.ts @@ -11,9 +11,6 @@ vi.mock('../infra/config/index.js', () => ({ loadGlobalConfig: vi.fn(() => ({})), })); -import { loadGlobalConfig } from '../infra/config/index.js'; -const mockLoadGlobalConfig = vi.mocked(loadGlobalConfig); - vi.mock('../infra/task/index.js', async (importOriginal) => ({ ...(await importOriginal>()), TaskRunner: vi.fn(), @@ -283,117 +280,4 @@ describe('resolveTaskExecution', () => { 'Clone created: /project/../20260128-info-task (branch: takt/20260128-info-task)' ); }); - - it('should return autoPr from task YAML when specified', async () => { - // Given: Task with auto_pr option - const task: TaskInfo = { - name: 'task-with-auto-pr', - content: 'Task content', - filePath: '/tasks/task.yaml', - data: { - task: 'Task content', - auto_pr: true, - }, - }; - - // When - const result = await resolveTaskExecution(task, '/project', 'default'); - - // Then - expect(result.autoPr).toBe(true); - }); - - it('should return autoPr: false from task YAML when specified as false', async () => { - // Given: Task with auto_pr: false - const task: TaskInfo = { - name: 'task-no-auto-pr', - content: 'Task content', - filePath: '/tasks/task.yaml', - data: { - task: 'Task content', - auto_pr: false, - }, - }; - - // When - const result = await resolveTaskExecution(task, '/project', 'default'); - - // Then - expect(result.autoPr).toBe(false); - }); - - it('should fall back to global config autoPr when task YAML does not specify', async () => { - // Given: Task without auto_pr, global config has autoPr - mockLoadGlobalConfig.mockReturnValue({ - language: 'en', - defaultPiece: 'default', - logLevel: 'info', - autoPr: true, - }); - - const task: TaskInfo = { - name: 'task-no-auto-pr-setting', - content: 'Task content', - filePath: '/tasks/task.yaml', - data: { - task: 'Task content', - }, - }; - - // When - const result = await resolveTaskExecution(task, '/project', 'default'); - - // Then - expect(result.autoPr).toBe(true); - }); - - it('should return undefined autoPr when neither task nor config specifies', async () => { - // Given: Neither task nor config has autoPr - mockLoadGlobalConfig.mockReturnValue({ - language: 'en', - defaultPiece: 'default', - logLevel: 'info', - }); - - const task: TaskInfo = { - name: 'task-default', - content: 'Task content', - filePath: '/tasks/task.yaml', - data: { - task: 'Task content', - }, - }; - - // When - const result = await resolveTaskExecution(task, '/project', 'default'); - - // Then - expect(result.autoPr).toBeUndefined(); - }); - - it('should prioritize task YAML auto_pr over global config', async () => { - // Given: Task has auto_pr: false, global config has autoPr: true - mockLoadGlobalConfig.mockReturnValue({ - language: 'en', - defaultPiece: 'default', - logLevel: 'info', - autoPr: true, - }); - - const task: TaskInfo = { - name: 'task-override', - content: 'Task content', - filePath: '/tasks/task.yaml', - data: { - task: 'Task content', - auto_pr: false, - }, - }; - - // When - const result = await resolveTaskExecution(task, '/project', 'default'); - - // Then - expect(result.autoPr).toBe(false); - }); }); diff --git a/src/core/models/global-config.ts b/src/core/models/global-config.ts index bd1976f..e0c49d5 100644 --- a/src/core/models/global-config.ts +++ b/src/core/models/global-config.ts @@ -43,8 +43,6 @@ export interface GlobalConfig { debug?: DebugConfig; /** Directory for shared clones (worktree_dir in config). If empty, uses ../{clone-name} relative to project */ worktreeDir?: string; - /** Auto-create PR after worktree execution (default: prompt in interactive mode) */ - autoPr?: boolean; /** List of builtin piece/agent names to exclude from fallback loading */ disabledBuiltins?: string[]; /** Enable builtin pieces from resources/global/{lang}/pieces */ diff --git a/src/core/models/schemas.ts b/src/core/models/schemas.ts index c3645b0..8000ca9 100644 --- a/src/core/models/schemas.ts +++ b/src/core/models/schemas.ts @@ -252,8 +252,6 @@ export const GlobalConfigSchema = z.object({ debug: DebugConfigSchema.optional(), /** Directory for shared clones (worktree_dir in config). If empty, uses ../{clone-name} relative to project */ worktree_dir: z.string().optional(), - /** Auto-create PR after worktree execution (default: prompt in interactive mode) */ - auto_pr: z.boolean().optional(), /** List of builtin piece/agent names to exclude from fallback loading */ disabled_builtins: z.array(z.string()).optional().default([]), /** Enable builtin pieces from resources/global/{lang}/pieces */ diff --git a/src/core/piece/engine/ParallelRunner.ts b/src/core/piece/engine/ParallelRunner.ts index ff2ff20..9acaa24 100644 --- a/src/core/piece/engine/ParallelRunner.ts +++ b/src/core/piece/engine/ParallelRunner.ts @@ -71,6 +71,10 @@ export class ParallelRunner { ? new ParallelLogger({ subMovementNames: subMovements.map((s) => s.name), parentOnStream: this.deps.engineOptions.onStream, + progressInfo: { + iteration: state.iteration, + maxIterations, + }, }) : undefined; diff --git a/src/core/piece/engine/parallel-logger.ts b/src/core/piece/engine/parallel-logger.ts index 48ae69a..562d903 100644 --- a/src/core/piece/engine/parallel-logger.ts +++ b/src/core/piece/engine/parallel-logger.ts @@ -12,6 +12,14 @@ import type { StreamCallback, StreamEvent } from '../types.js'; const COLORS = ['\x1b[36m', '\x1b[33m', '\x1b[35m', '\x1b[32m'] as const; // cyan, yellow, magenta, green const RESET = '\x1b[0m'; +/** Progress information for parallel logger */ +export interface ParallelProgressInfo { + /** Current iteration (1-indexed) */ + iteration: number; + /** Maximum iterations allowed */ + maxIterations: number; +} + export interface ParallelLoggerOptions { /** Sub-movement names (used to calculate prefix width) */ subMovementNames: string[]; @@ -19,6 +27,8 @@ export interface ParallelLoggerOptions { parentOnStream?: StreamCallback; /** Override process.stdout.write for testing */ writeFn?: (text: string) => void; + /** Progress information for display */ + progressInfo?: ParallelProgressInfo; } /** @@ -34,11 +44,15 @@ export class ParallelLogger { private readonly lineBuffers: Map = new Map(); private readonly parentOnStream?: StreamCallback; private readonly writeFn: (text: string) => void; + private readonly progressInfo?: ParallelProgressInfo; + private readonly totalSubMovements: number; constructor(options: ParallelLoggerOptions) { this.maxNameLength = Math.max(...options.subMovementNames.map((n) => n.length)); this.parentOnStream = options.parentOnStream; this.writeFn = options.writeFn ?? ((text: string) => process.stdout.write(text)); + this.progressInfo = options.progressInfo; + this.totalSubMovements = options.subMovementNames.length; for (const name of options.subMovementNames) { this.lineBuffers.set(name, ''); @@ -47,12 +61,20 @@ export class ParallelLogger { /** * Build the colored prefix string for a sub-movement. - * Format: `\x1b[COLORm[name]\x1b[0m` + padding spaces + * Format: `\x1b[COLORm[name](iteration/max) step index/total\x1b[0m` + padding spaces */ buildPrefix(name: string, index: number): string { const color = COLORS[index % COLORS.length]; const padding = ' '.repeat(this.maxNameLength - name.length); - return `${color}[${name}]${RESET}${padding} `; + + let progressPart = ''; + if (this.progressInfo) { + const { iteration, maxIterations } = this.progressInfo; + // index is 0-indexed, display as 1-indexed for step number + progressPart = `(${iteration}/${maxIterations}) step ${index + 1}/${this.totalSubMovements} `; + } + + return `${color}[${name}]${RESET}${padding} ${progressPart}`; } /** diff --git a/src/features/tasks/add/index.ts b/src/features/tasks/add/index.ts index 540d25d..7dadf17 100644 --- a/src/features/tasks/add/index.ts +++ b/src/features/tasks/add/index.ts @@ -43,7 +43,7 @@ async function generateFilename(tasksDir: string, taskContent: string, cwd: stri export async function saveTaskFile( cwd: string, taskContent: string, - options?: { piece?: string; issue?: number; worktree?: boolean | string; branch?: string; autoPr?: boolean }, + options?: { piece?: string; issue?: number; worktree?: boolean | string; branch?: string }, ): Promise { const tasksDir = path.join(cwd, '.takt', 'tasks'); fs.mkdirSync(tasksDir, { recursive: true }); @@ -57,7 +57,6 @@ export async function saveTaskFile( ...(options?.branch && { branch: options.branch }), ...(options?.piece && { piece: options.piece }), ...(options?.issue !== undefined && { issue: options.issue }), - ...(options?.autoPr !== undefined && { auto_pr: options.autoPr }), }; const filePath = path.join(tasksDir, filename); @@ -170,10 +169,9 @@ export async function addTask(cwd: string, task?: string): Promise { taskContent = result.task; } - // ワークツリー/ブランチ/PR設定 + // ワークツリー/ブランチ設定 let worktree: boolean | string | undefined; let branch: string | undefined; - let autoPr: boolean | undefined; const useWorktree = await confirm('Create worktree?', true); if (useWorktree) { @@ -184,8 +182,6 @@ export async function addTask(cwd: string, task?: string): Promise { if (customBranch) { branch = customBranch; } - - autoPr = await confirm('Auto-create PR?', false); } // YAMLファイル作成 @@ -194,7 +190,6 @@ export async function addTask(cwd: string, task?: string): Promise { issue: issueNumber, worktree, branch, - autoPr, }); const filename = path.basename(filePath); @@ -206,9 +201,6 @@ export async function addTask(cwd: string, task?: string): Promise { if (branch) { info(` Branch: ${branch}`); } - if (autoPr) { - info(` Auto-PR: yes`); - } if (piece) { info(` Piece: ${piece}`); } diff --git a/src/features/tasks/execute/pieceExecution.ts b/src/features/tasks/execute/pieceExecution.ts index d39790a..af99077 100644 --- a/src/features/tasks/execute/pieceExecution.ts +++ b/src/features/tasks/execute/pieceExecution.ts @@ -273,8 +273,17 @@ export async function executePiece( log.debug('Step instruction', instruction); } + // Find movement index for progress display + const movementIndex = pieceConfig.movements.findIndex((m) => m.name === step.name); + const totalMovements = pieceConfig.movements.length; + // Use quiet mode from CLI (already resolved CLI flag + config in preAction) - displayRef.current = new StreamDisplay(step.agentDisplayName, isQuietMode()); + displayRef.current = new StreamDisplay(step.agentDisplayName, isQuietMode(), { + iteration, + maxIterations: pieceConfig.maxIterations, + movementIndex: movementIndex >= 0 ? movementIndex : 0, + totalMovements, + }); // Write step_start record to NDJSON log const record: NdjsonStepStart = { diff --git a/src/features/tasks/execute/selectAndExecute.ts b/src/features/tasks/execute/selectAndExecute.ts index d64fee4..ca0d811 100644 --- a/src/features/tasks/execute/selectAndExecute.ts +++ b/src/features/tasks/execute/selectAndExecute.ts @@ -14,14 +14,14 @@ import { loadAllPiecesWithSources, getPieceCategories, buildCategorizedPieces, - loadGlobalConfig, } from '../../../infra/config/index.js'; import { confirm } from '../../../shared/prompt/index.js'; import { createSharedClone, autoCommitAndPush, summarizeTaskName } from '../../../infra/task/index.js'; import { DEFAULT_PIECE_NAME } from '../../../shared/constants.js'; import { info, error, success } from '../../../shared/ui/index.js'; import { createLogger } from '../../../shared/utils/index.js'; -import { executeTask, pushAndCreatePr } from './taskExecution.js'; +import { createPullRequest, buildPrBody } from '../../../infra/github/index.js'; +import { executeTask } from './taskExecution.js'; import type { TaskExecutionOptions, WorktreeConfirmationResult, SelectAndExecuteOptions } from './types.js'; import { warnMissingPieces, @@ -122,26 +122,6 @@ export async function confirmAndCreateWorktree( return { execCwd: result.path, isWorktree: true, branch: result.branch }; } -/** - * Resolve auto-PR setting with priority: CLI option > config > prompt. - * Only applicable when worktree is enabled. - */ -async function resolveAutoPr(optionAutoPr: boolean | undefined): Promise { - // CLI option takes precedence - if (typeof optionAutoPr === 'boolean') { - return optionAutoPr; - } - - // Check global config - const globalConfig = loadGlobalConfig(); - if (typeof globalConfig.autoPr === 'boolean') { - return globalConfig.autoPr; - } - - // Fall back to interactive prompt - return confirm('Create pull request?', false); -} - /** * Execute a task with piece selection, optional worktree, and auto-commit. * Shared by direct task execution and interactive mode. @@ -165,12 +145,7 @@ export async function selectAndExecuteTask( options?.createWorktree, ); - let shouldCreatePr = false; - if (isWorktree) { - shouldCreatePr = await resolveAutoPr(options?.autoPr); - } - - log.info('Starting task execution', { piece: pieceIdentifier, worktree: isWorktree, autoPr: shouldCreatePr }); + log.info('Starting task execution', { piece: pieceIdentifier, worktree: isWorktree }); const taskSuccess = await executeTask({ task, cwd: execCwd, @@ -189,14 +164,23 @@ export async function selectAndExecuteTask( error(`Auto-commit failed: ${commitResult.message}`); } - if (commitResult.success && commitResult.commitHash && branch && shouldCreatePr) { - pushAndCreatePr( - cwd, - branch, - task, - `Piece \`${pieceIdentifier}\` completed successfully.`, - { issues: options?.issues, repo: options?.repo }, - ); + if (commitResult.success && commitResult.commitHash && branch) { + const shouldCreatePr = options?.autoPr === true || await confirm('Create pull request?', false); + if (shouldCreatePr) { + info('Creating pull request...'); + const prBody = buildPrBody(options?.issues, `Piece \`${pieceIdentifier}\` completed successfully.`); + const prResult = createPullRequest(execCwd, { + branch, + title: task.length > 100 ? `${task.slice(0, 97)}...` : task, + body: prBody, + repo: options?.repo, + }); + if (prResult.success) { + success(`PR created: ${prResult.url}`); + } else { + error(`PR creation failed: ${prResult.error}`); + } + } } } diff --git a/src/features/tasks/execute/taskExecution.ts b/src/features/tasks/execute/taskExecution.ts index ad1bbc1..e97bde4 100644 --- a/src/features/tasks/execute/taskExecution.ts +++ b/src/features/tasks/execute/taskExecution.ts @@ -16,7 +16,6 @@ import { createLogger, getErrorMessage } from '../../../shared/utils/index.js'; import { executePiece } from './pieceExecution.js'; import { DEFAULT_PIECE_NAME } from '../../../shared/constants.js'; import type { TaskExecutionOptions, ExecuteTaskOptions } from './types.js'; -import { createPullRequest, buildPrBody, pushBranch, type GitHubIssue } from '../../../infra/github/index.js'; export type { TaskExecutionOptions, ExecuteTaskOptions }; @@ -59,39 +58,6 @@ export async function executeTask(options: ExecuteTaskOptions): Promise return result.success; } -/** - * Push branch to origin and create a PR. - * clone の origin は shared clone 作成時に削除されるため、project cwd 経由で push する。 - */ -export function pushAndCreatePr( - cwd: string, - branch: string, - title: string, - description: string, - options?: { issues?: GitHubIssue[]; repo?: string }, -): void { - info('Creating pull request...'); - try { - pushBranch(cwd, branch); - } catch (e) { - error(`Failed to push branch to origin: ${getErrorMessage(e)}`); - return; - } - const prBody = buildPrBody(options?.issues, description); - const truncatedTitle = title.length > 100 ? `${title.slice(0, 97)}...` : title; - const prResult = createPullRequest(cwd, { - branch, - title: truncatedTitle, - body: prBody, - repo: options?.repo, - }); - if (prResult.success) { - success(`PR created: ${prResult.url}`); - } else { - error(`PR creation failed: ${prResult.error}`); - } -} - /** * Execute a task: resolve clone → run piece → auto-commit+push → remove clone → record completion. * @@ -111,7 +77,7 @@ export async function executeAndCompleteTask( const executionLog: string[] = []; try { - const { execCwd, execPiece, isWorktree, branch, startMovement, retryNote, autoPr } = await resolveTaskExecution(task, cwd, pieceName); + const { execCwd, execPiece, isWorktree, startMovement, retryNote } = await resolveTaskExecution(task, cwd, pieceName); // cwd is always the project root; pass it as projectCwd so reports/sessions go there const taskSuccess = await executeTask({ @@ -132,10 +98,6 @@ export async function executeAndCompleteTask( } else if (!commitResult.success) { error(`Auto-commit failed: ${commitResult.message}`); } - - if (commitResult.success && commitResult.commitHash && branch && autoPr) { - pushAndCreatePr(cwd, branch, task.name, `Task "${task.name}" completed successfully.`); - } } const taskResult = { @@ -236,7 +198,7 @@ export async function resolveTaskExecution( task: TaskInfo, defaultCwd: string, defaultPiece: string -): Promise<{ execCwd: string; execPiece: string; isWorktree: boolean; branch?: string; startMovement?: string; retryNote?: string; autoPr?: boolean }> { +): Promise<{ execCwd: string; execPiece: string; isWorktree: boolean; branch?: string; startMovement?: string; retryNote?: string }> { const data = task.data; // No structured data: use defaults @@ -275,14 +237,5 @@ export async function resolveTaskExecution( // Handle retry_note const retryNote = data.retry_note; - // Handle auto_pr (task YAML > global config) - let autoPr: boolean | undefined; - if (data.auto_pr !== undefined) { - autoPr = data.auto_pr; - } else { - const globalConfig = loadGlobalConfig(); - autoPr = globalConfig.autoPr; - } - - return { execCwd, execPiece, isWorktree, branch, startMovement, retryNote, autoPr }; + return { execCwd, execPiece, isWorktree, branch, startMovement, retryNote }; } diff --git a/src/infra/config/global/globalConfig.ts b/src/infra/config/global/globalConfig.ts index 6597066..5fff55d 100644 --- a/src/infra/config/global/globalConfig.ts +++ b/src/infra/config/global/globalConfig.ts @@ -75,7 +75,6 @@ export class GlobalConfigManager { logFile: parsed.debug.log_file, } : undefined, worktreeDir: parsed.worktree_dir, - autoPr: parsed.auto_pr, disabledBuiltins: parsed.disabled_builtins, enableBuiltinPieces: parsed.enable_builtin_pieces, anthropicApiKey: parsed.anthropic_api_key, @@ -115,9 +114,6 @@ export class GlobalConfigManager { if (config.worktreeDir) { raw.worktree_dir = config.worktreeDir; } - if (config.autoPr !== undefined) { - raw.auto_pr = config.autoPr; - } if (config.disabledBuiltins && config.disabledBuiltins.length > 0) { raw.disabled_builtins = config.disabledBuiltins; } diff --git a/src/infra/task/schema.ts b/src/infra/task/schema.ts index 051d189..32401e2 100644 --- a/src/infra/task/schema.ts +++ b/src/infra/task/schema.ts @@ -32,8 +32,6 @@ export const TaskFileSchema = z.object({ issue: z.number().int().positive().optional(), start_movement: z.string().optional(), retry_note: z.string().optional(), - /** Auto-create PR after worktree execution (default: prompt in interactive mode) */ - auto_pr: z.boolean().optional(), }); export type TaskFileData = z.infer; diff --git a/src/shared/ui/StreamDisplay.ts b/src/shared/ui/StreamDisplay.ts index d54532e..53308be 100644 --- a/src/shared/ui/StreamDisplay.ts +++ b/src/shared/ui/StreamDisplay.ts @@ -13,6 +13,18 @@ import chalk from 'chalk'; import type { StreamEvent, StreamCallback } from '../../core/piece/index.js'; import { truncate } from './LogManager.js'; +/** Progress information for stream display */ +export interface ProgressInfo { + /** Current iteration (1-indexed) */ + iteration: number; + /** Maximum iterations allowed */ + maxIterations: number; + /** Current movement index within piece (0-indexed) */ + movementIndex: number; + /** Total number of movements in piece */ + totalMovements: number; +} + /** Stream display manager for real-time Claude output */ export class StreamDisplay { private lastToolUse: string | null = null; @@ -32,13 +44,30 @@ export class StreamDisplay { private spinnerFrame = 0; constructor( - private agentName = 'Claude', - private quiet = false, + private agentName: string, + private quiet: boolean, + private progressInfo?: ProgressInfo, ) {} + /** + * Build progress prefix string for display. + * Format: `(iteration/maxIterations) step movementIndex/totalMovements` + * Example: `(3/10) step 2/4` + */ + private buildProgressPrefix(): string { + if (!this.progressInfo) { + return ''; + } + const { iteration, maxIterations, movementIndex, totalMovements } = this.progressInfo; + // movementIndex is 0-indexed, display as 1-indexed + return `(${iteration}/${maxIterations}) step ${movementIndex + 1}/${totalMovements}`; + } + showInit(model: string): void { if (this.quiet) return; - console.log(chalk.gray(`[${this.agentName}] Model: ${model}`)); + const progress = this.buildProgressPrefix(); + const progressPart = progress ? ` ${progress}` : ''; + console.log(chalk.gray(`[${this.agentName}]${progressPart} Model: ${model}`)); } private startToolSpinner(tool: string, inputPreview: string): void { @@ -140,7 +169,9 @@ export class StreamDisplay { if (this.isFirstThinking) { console.log(); - console.log(chalk.magenta(`💭 [${this.agentName} thinking]:`)); + const progress = this.buildProgressPrefix(); + const progressPart = progress ? ` ${progress}` : ''; + console.log(chalk.magenta(`💭 [${this.agentName}]${progressPart} thinking:`)); this.isFirstThinking = false; } process.stdout.write(chalk.gray.italic(thinking)); @@ -164,7 +195,9 @@ export class StreamDisplay { if (this.isFirstText) { console.log(); - console.log(chalk.cyan(`[${this.agentName}]:`)); + const progress = this.buildProgressPrefix(); + const progressPart = progress ? ` ${progress}` : ''; + console.log(chalk.cyan(`[${this.agentName}]${progressPart}:`)); this.isFirstText = false; } process.stdout.write(text); diff --git a/src/shared/ui/index.ts b/src/shared/ui/index.ts index 9e0e2be..22df098 100644 --- a/src/shared/ui/index.ts +++ b/src/shared/ui/index.ts @@ -28,4 +28,4 @@ export { export { Spinner } from './Spinner.js'; -export { StreamDisplay } from './StreamDisplay.js'; +export { StreamDisplay, type ProgressInfo } from './StreamDisplay.js';