From 973c7df85dd54577335797999b3f6325084b1447 Mon Sep 17 00:00:00 2001 From: nrslib <38722970+nrslib@users.noreply.github.com> Date: Fri, 6 Feb 2026 18:48:09 +0900 Subject: [PATCH 1/4] =?UTF-8?q?issue=E5=8F=82=E7=85=A7=E6=99=82=E3=81=AB?= =?UTF-8?q?=E3=82=82=E3=83=94=E3=83=BC=E3=82=B9=E9=81=B8=E6=8A=9E=E3=82=92?= =?UTF-8?q?=E5=AE=9F=E6=96=BD=20#97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/__tests__/addTask.test.ts | 42 +++++++++++++++++++++++++++++++++ src/features/tasks/add/index.ts | 22 ++++++++++------- 2 files changed, 55 insertions(+), 9 deletions(-) diff --git a/src/__tests__/addTask.test.ts b/src/__tests__/addTask.test.ts index 49036e7..bd05b25 100644 --- a/src/__tests__/addTask.test.ts +++ b/src/__tests__/addTask.test.ts @@ -275,6 +275,7 @@ describe('addTask', () => { // Given: issue reference "#99" const issueText = 'Issue #99: Fix login timeout\n\nThe login page times out after 30 seconds.'; mockResolveIssueTask.mockReturnValue(issueText); + mockDeterminePiece.mockResolvedValue('default'); mockSummarizeTaskName.mockResolvedValue('fix-login-timeout'); mockConfirm.mockResolvedValue(false); @@ -288,6 +289,9 @@ describe('addTask', () => { // Then: resolveIssueTask was called expect(mockResolveIssueTask).toHaveBeenCalledWith('#99'); + // Then: determinePiece was called for piece selection + expect(mockDeterminePiece).toHaveBeenCalledWith(testDir); + // Then: task file created with issue text directly (no AI summarization) const taskFile = path.join(testDir, '.takt', 'tasks', 'fix-login-timeout.yaml'); expect(fs.existsSync(taskFile)).toBe(true); @@ -298,6 +302,7 @@ describe('addTask', () => { it('should proceed to worktree settings after issue fetch', async () => { // Given: issue with worktree enabled mockResolveIssueTask.mockReturnValue('Issue text'); + mockDeterminePiece.mockResolvedValue('default'); mockSummarizeTaskName.mockResolvedValue('issue-task'); mockConfirm.mockResolvedValue(true); mockPromptInput @@ -331,6 +336,7 @@ describe('addTask', () => { // Given: issue reference "#99" const issueText = 'Issue #99: Fix login timeout'; mockResolveIssueTask.mockReturnValue(issueText); + mockDeterminePiece.mockResolvedValue('default'); mockSummarizeTaskName.mockResolvedValue('fix-login-timeout'); mockConfirm.mockResolvedValue(false); @@ -344,6 +350,42 @@ describe('addTask', () => { expect(content).toContain('issue: 99'); }); + it('should include piece selection in task file when issue reference is used', async () => { + // Given: issue reference "#99" with non-default piece selection + const issueText = 'Issue #99: Fix login timeout'; + mockResolveIssueTask.mockReturnValue(issueText); + mockDeterminePiece.mockResolvedValue('review'); + mockSummarizeTaskName.mockResolvedValue('fix-login-timeout'); + mockConfirm.mockResolvedValue(false); + + // When + await addTask(testDir, '#99'); + + // Then: task file contains piece field + const taskFile = path.join(testDir, '.takt', 'tasks', 'fix-login-timeout.yaml'); + expect(fs.existsSync(taskFile)).toBe(true); + const content = fs.readFileSync(taskFile, 'utf-8'); + expect(content).toContain('piece: review'); + }); + + it('should cancel when piece selection returns null for issue reference', async () => { + // Given: issue fetched successfully but user cancels piece selection + const issueText = 'Issue #99: Fix login timeout'; + mockResolveIssueTask.mockReturnValue(issueText); + mockDeterminePiece.mockResolvedValue(null); + + // When + await addTask(testDir, '#99'); + + // Then: no task file created (cancelled at piece selection) + const tasksDir = path.join(testDir, '.takt', 'tasks'); + const files = fs.readdirSync(tasksDir); + expect(files.length).toBe(0); + + // Then: issue was fetched before cancellation + expect(mockResolveIssueTask).toHaveBeenCalledWith('#99'); + }); + describe('create_issue action', () => { it('should call createIssue when create_issue action is selected', async () => { // Given: interactive mode returns create_issue action diff --git a/src/features/tasks/add/index.ts b/src/features/tasks/add/index.ts index d90788d..7dadf17 100644 --- a/src/features/tasks/add/index.ts +++ b/src/features/tasks/add/index.ts @@ -106,18 +106,14 @@ export async function saveTaskFromInteractive( * add command handler * * Flow: - * 1. ピース選択 - * 2. AI対話モードでタスクを詰める - * 3. 会話履歴からAIがタスク要約を生成 - * 4. 要約からファイル名をAIで生成 - * 5. ワークツリー/ブランチ設定 - * 6. YAMLファイル作成 + * A) Issue参照の場合: issue取得 → ピース選択 → ワークツリー設定 → YAML作成 + * B) それ以外: ピース選択 → AI対話モード → ワークツリー設定 → YAML作成 */ export async function addTask(cwd: string, task?: string): Promise { const tasksDir = path.join(cwd, '.takt', 'tasks'); fs.mkdirSync(tasksDir, { recursive: true }); - // 1. ピース選択(Issue参照以外の場合、対話モードの前に実施) + // ピース選択とタスク内容の決定 let taskContent: string; let issueNumber: number | undefined; let piece: string | undefined; @@ -137,6 +133,14 @@ export async function addTask(cwd: string, task?: string): Promise { info(`Failed to fetch issue ${task}: ${msg}`); return; } + + // ピース選択(issue取得成功後) + const pieceId = await determinePiece(cwd); + if (pieceId === null) { + info('Cancelled.'); + return; + } + piece = pieceId; } else { // ピース選択を先に行い、結果を対話モードに渡す const pieceId = await determinePiece(cwd); @@ -165,7 +169,7 @@ export async function addTask(cwd: string, task?: string): Promise { taskContent = result.task; } - // 3. ワークツリー/ブランチ設定 + // ワークツリー/ブランチ設定 let worktree: boolean | string | undefined; let branch: string | undefined; @@ -180,7 +184,7 @@ export async function addTask(cwd: string, task?: string): Promise { } } - // 4. YAMLファイル作成 + // YAMLファイル作成 const filePath = await saveTaskFile(cwd, taskContent, { piece, issue: issueNumber, From 24361b34e30e38bf361f32cf559e36b257e49fcb Mon Sep 17 00:00:00 2001 From: nrslib <38722970+nrslib@users.noreply.github.com> Date: Fri, 6 Feb 2026 19:07:18 +0900 Subject: [PATCH 2/4] =?UTF-8?q?auto-PR=20=E6=A9=9F=E8=83=BD=E3=81=AE?= =?UTF-8?q?=E8=BF=BD=E5=8A=A0=E3=81=A8PR=E4=BD=9C=E6=88=90=E3=83=AD?= =?UTF-8?q?=E3=82=B8=E3=83=83=E3=82=AF=E3=81=AE=E5=85=B1=E9=80=9A=E5=8C=96?= =?UTF-8?q?=20#98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/__tests__/globalConfig-defaults.test.ts | 43 +++++++ src/__tests__/saveTaskFile.test.ts | 19 +++ src/__tests__/taskExecution.test.ts | 116 ++++++++++++++++++ src/core/models/global-config.ts | 2 + src/core/models/schemas.ts | 2 + src/features/tasks/add/index.ts | 12 +- .../tasks/execute/selectAndExecute.ts | 56 ++++++--- src/features/tasks/execute/taskExecution.ts | 53 +++++++- src/infra/config/global/globalConfig.ts | 4 + src/infra/task/schema.ts | 2 + 10 files changed, 284 insertions(+), 25 deletions(-) diff --git a/src/__tests__/globalConfig-defaults.test.ts b/src/__tests__/globalConfig-defaults.test.ts index e120d0e..9854205 100644 --- a/src/__tests__/globalConfig-defaults.test.ts +++ b/src/__tests__/globalConfig-defaults.test.ts @@ -121,6 +121,49 @@ 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__/saveTaskFile.test.ts b/src/__tests__/saveTaskFile.test.ts index 6c087ad..5111656 100644 --- a/src/__tests__/saveTaskFile.test.ts +++ b/src/__tests__/saveTaskFile.test.ts @@ -121,6 +121,25 @@ 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 735cb38..89212c7 100644 --- a/src/__tests__/taskExecution.test.ts +++ b/src/__tests__/taskExecution.test.ts @@ -11,6 +11,9 @@ 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(), @@ -280,4 +283,117 @@ 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 e0c49d5..bd1976f 100644 --- a/src/core/models/global-config.ts +++ b/src/core/models/global-config.ts @@ -43,6 +43,8 @@ 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 8000ca9..c3645b0 100644 --- a/src/core/models/schemas.ts +++ b/src/core/models/schemas.ts @@ -252,6 +252,8 @@ 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/features/tasks/add/index.ts b/src/features/tasks/add/index.ts index 7dadf17..540d25d 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 }, + options?: { piece?: string; issue?: number; worktree?: boolean | string; branch?: string; autoPr?: boolean }, ): Promise { const tasksDir = path.join(cwd, '.takt', 'tasks'); fs.mkdirSync(tasksDir, { recursive: true }); @@ -57,6 +57,7 @@ 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); @@ -169,9 +170,10 @@ 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) { @@ -182,6 +184,8 @@ export async function addTask(cwd: string, task?: string): Promise { if (customBranch) { branch = customBranch; } + + autoPr = await confirm('Auto-create PR?', false); } // YAMLファイル作成 @@ -190,6 +194,7 @@ export async function addTask(cwd: string, task?: string): Promise { issue: issueNumber, worktree, branch, + autoPr, }); const filename = path.basename(filePath); @@ -201,6 +206,9 @@ 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/selectAndExecute.ts b/src/features/tasks/execute/selectAndExecute.ts index ca0d811..d64fee4 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 { createPullRequest, buildPrBody } from '../../../infra/github/index.js'; -import { executeTask } from './taskExecution.js'; +import { executeTask, pushAndCreatePr } from './taskExecution.js'; import type { TaskExecutionOptions, WorktreeConfirmationResult, SelectAndExecuteOptions } from './types.js'; import { warnMissingPieces, @@ -122,6 +122,26 @@ 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. @@ -145,7 +165,12 @@ export async function selectAndExecuteTask( options?.createWorktree, ); - log.info('Starting task execution', { piece: pieceIdentifier, worktree: isWorktree }); + let shouldCreatePr = false; + if (isWorktree) { + shouldCreatePr = await resolveAutoPr(options?.autoPr); + } + + log.info('Starting task execution', { piece: pieceIdentifier, worktree: isWorktree, autoPr: shouldCreatePr }); const taskSuccess = await executeTask({ task, cwd: execCwd, @@ -164,23 +189,14 @@ export async function selectAndExecuteTask( error(`Auto-commit failed: ${commitResult.message}`); } - 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}`); - } - } + if (commitResult.success && commitResult.commitHash && branch && shouldCreatePr) { + pushAndCreatePr( + cwd, + branch, + task, + `Piece \`${pieceIdentifier}\` completed successfully.`, + { issues: options?.issues, repo: options?.repo }, + ); } } diff --git a/src/features/tasks/execute/taskExecution.ts b/src/features/tasks/execute/taskExecution.ts index e97bde4..ad1bbc1 100644 --- a/src/features/tasks/execute/taskExecution.ts +++ b/src/features/tasks/execute/taskExecution.ts @@ -16,6 +16,7 @@ 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 }; @@ -58,6 +59,39 @@ 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. * @@ -77,7 +111,7 @@ export async function executeAndCompleteTask( const executionLog: string[] = []; try { - const { execCwd, execPiece, isWorktree, startMovement, retryNote } = await resolveTaskExecution(task, cwd, pieceName); + const { execCwd, execPiece, isWorktree, branch, startMovement, retryNote, autoPr } = await resolveTaskExecution(task, cwd, pieceName); // cwd is always the project root; pass it as projectCwd so reports/sessions go there const taskSuccess = await executeTask({ @@ -98,6 +132,10 @@ 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 = { @@ -198,7 +236,7 @@ export async function resolveTaskExecution( task: TaskInfo, defaultCwd: string, defaultPiece: string -): Promise<{ execCwd: string; execPiece: string; isWorktree: boolean; branch?: string; startMovement?: string; retryNote?: string }> { +): Promise<{ execCwd: string; execPiece: string; isWorktree: boolean; branch?: string; startMovement?: string; retryNote?: string; autoPr?: boolean }> { const data = task.data; // No structured data: use defaults @@ -237,5 +275,14 @@ export async function resolveTaskExecution( // Handle retry_note const retryNote = data.retry_note; - return { execCwd, execPiece, isWorktree, branch, startMovement, retryNote }; + // 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 }; } diff --git a/src/infra/config/global/globalConfig.ts b/src/infra/config/global/globalConfig.ts index 5fff55d..6597066 100644 --- a/src/infra/config/global/globalConfig.ts +++ b/src/infra/config/global/globalConfig.ts @@ -75,6 +75,7 @@ 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, @@ -114,6 +115,9 @@ 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 32401e2..051d189 100644 --- a/src/infra/task/schema.ts +++ b/src/infra/task/schema.ts @@ -32,6 +32,8 @@ 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; From d479707d1b5f87efdc77cea1138155f3870054dc Mon Sep 17 00:00:00 2001 From: nrslib <38722970+nrslib@users.noreply.github.com> Date: Fri, 6 Feb 2026 20:04:51 +0900 Subject: [PATCH 3/4] takt: github-issue-106-suteppu-niite --- src/__tests__/StreamDisplay.test.ts | 177 ++++++++++++++++++ src/__tests__/globalConfig-defaults.test.ts | 43 ----- src/__tests__/parallel-logger.test.ts | 68 +++++++ src/__tests__/saveTaskFile.test.ts | 19 -- src/__tests__/taskExecution.test.ts | 116 ------------ src/core/models/global-config.ts | 2 - src/core/models/schemas.ts | 2 - src/core/piece/engine/ParallelRunner.ts | 4 + src/core/piece/engine/parallel-logger.ts | 26 ++- src/features/tasks/add/index.ts | 12 +- src/features/tasks/execute/pieceExecution.ts | 11 +- .../tasks/execute/selectAndExecute.ts | 56 ++---- src/features/tasks/execute/taskExecution.ts | 53 +----- src/infra/config/global/globalConfig.ts | 4 - src/infra/task/schema.ts | 2 - src/shared/ui/StreamDisplay.ts | 43 ++++- src/shared/ui/index.ts | 2 +- 17 files changed, 347 insertions(+), 293 deletions(-) create mode 100644 src/__tests__/StreamDisplay.test.ts 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'; From b9c47d29a8ed4ac4a5218e9abe620e2f8264244c Mon Sep 17 00:00:00 2001 From: nrslib <38722970+nrslib@users.noreply.github.com> Date: Fri, 6 Feb 2026 20:33:55 +0900 Subject: [PATCH 4/4] takt: github-issue-100-macosdesuriip --- src/__tests__/globalConfig-defaults.test.ts | 47 ++++++++ src/__tests__/sleep.test.ts | 114 +++++++++++++++++++ src/core/models/global-config.ts | 2 + src/core/models/schemas.ts | 2 + src/features/tasks/execute/pieceExecution.ts | 10 +- src/infra/config/global/globalConfig.ts | 4 + src/shared/utils/index.ts | 1 + src/shared/utils/sleep.ts | 46 ++++++++ 8 files changed, 224 insertions(+), 2 deletions(-) create mode 100644 src/__tests__/sleep.test.ts create mode 100644 src/shared/utils/sleep.ts diff --git a/src/__tests__/globalConfig-defaults.test.ts b/src/__tests__/globalConfig-defaults.test.ts index 9854205..9718d1e 100644 --- a/src/__tests__/globalConfig-defaults.test.ts +++ b/src/__tests__/globalConfig-defaults.test.ts @@ -193,4 +193,51 @@ describe('loadGlobalConfig', () => { expect(config3.language).toBe('en'); expect(config3).not.toBe(config1); }); + + it('should load prevent_sleep config from config.yaml', () => { + const taktDir = join(testHomeDir, '.takt'); + mkdirSync(taktDir, { recursive: true }); + writeFileSync( + getGlobalConfigPath(), + 'language: en\nprevent_sleep: true\n', + 'utf-8', + ); + + const config = loadGlobalConfig(); + + expect(config.preventSleep).toBe(true); + }); + + it('should save and reload prevent_sleep config', () => { + const taktDir = join(testHomeDir, '.takt'); + mkdirSync(taktDir, { recursive: true }); + writeFileSync(getGlobalConfigPath(), 'language: en\n', 'utf-8'); + + const config = loadGlobalConfig(); + config.preventSleep = true; + saveGlobalConfig(config); + invalidateGlobalConfigCache(); + + const reloaded = loadGlobalConfig(); + expect(reloaded.preventSleep).toBe(true); + }); + + it('should save prevent_sleep: false when explicitly set', () => { + const taktDir = join(testHomeDir, '.takt'); + mkdirSync(taktDir, { recursive: true }); + writeFileSync(getGlobalConfigPath(), 'language: en\n', 'utf-8'); + + const config = loadGlobalConfig(); + config.preventSleep = false; + saveGlobalConfig(config); + invalidateGlobalConfigCache(); + + const reloaded = loadGlobalConfig(); + expect(reloaded.preventSleep).toBe(false); + }); + + it('should have undefined preventSleep by default', () => { + const config = loadGlobalConfig(); + expect(config.preventSleep).toBeUndefined(); + }); }); diff --git a/src/__tests__/sleep.test.ts b/src/__tests__/sleep.test.ts new file mode 100644 index 0000000..e7e821b --- /dev/null +++ b/src/__tests__/sleep.test.ts @@ -0,0 +1,114 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import type { ChildProcess } from 'node:child_process'; + +// Mock modules before importing the module under test +vi.mock('node:child_process', () => ({ + spawn: vi.fn(), +})); + +vi.mock('node:os', () => ({ + platform: vi.fn(), +})); + +vi.mock('node:fs', () => ({ + existsSync: vi.fn(), +})); + +// Mock the debug logger +vi.mock('../shared/utils/debug.js', () => ({ + createLogger: () => ({ + debug: vi.fn(), + info: vi.fn(), + error: vi.fn(), + enter: vi.fn(), + exit: vi.fn(), + }), +})); + +// Import after mocks are set up +const { spawn } = await import('node:child_process'); +const { platform } = await import('node:os'); +const { existsSync } = await import('node:fs'); +const { preventSleep, resetPreventSleepState } = await import('../shared/utils/sleep.js'); + +describe('preventSleep', () => { + beforeEach(() => { + vi.clearAllMocks(); + resetPreventSleepState(); + }); + + afterEach(() => { + vi.restoreAllMocks(); + }); + + it('should do nothing on non-darwin platforms', () => { + vi.mocked(platform).mockReturnValue('linux'); + + preventSleep(); + + expect(spawn).not.toHaveBeenCalled(); + }); + + it('should do nothing on Windows', () => { + vi.mocked(platform).mockReturnValue('win32'); + + preventSleep(); + + expect(spawn).not.toHaveBeenCalled(); + }); + + it('should spawn caffeinate on macOS when available', () => { + vi.mocked(platform).mockReturnValue('darwin'); + vi.mocked(existsSync).mockReturnValue(true); + + const mockChild = { + unref: vi.fn(), + pid: 12345, + } as unknown as ChildProcess; + vi.mocked(spawn).mockReturnValue(mockChild); + + preventSleep(); + + expect(spawn).toHaveBeenCalledWith( + '/usr/bin/caffeinate', + ['-i', '-w', String(process.pid)], + { stdio: 'ignore', detached: true } + ); + expect(mockChild.unref).toHaveBeenCalled(); + }); + + it('should not spawn caffeinate if not found', () => { + vi.mocked(platform).mockReturnValue('darwin'); + vi.mocked(existsSync).mockReturnValue(false); + + preventSleep(); + + expect(spawn).not.toHaveBeenCalled(); + }); + + it('should check for caffeinate at /usr/bin/caffeinate', () => { + vi.mocked(platform).mockReturnValue('darwin'); + vi.mocked(existsSync).mockReturnValue(false); + + preventSleep(); + + expect(existsSync).toHaveBeenCalledWith('/usr/bin/caffeinate'); + }); + + it('should only spawn caffeinate once even when called multiple times', () => { + vi.mocked(platform).mockReturnValue('darwin'); + vi.mocked(existsSync).mockReturnValue(true); + + const mockChild = { + unref: vi.fn(), + pid: 12345, + } as unknown as ChildProcess; + vi.mocked(spawn).mockReturnValue(mockChild); + + preventSleep(); + preventSleep(); + preventSleep(); + + expect(spawn).toHaveBeenCalledTimes(1); + }); +}); diff --git a/src/core/models/global-config.ts b/src/core/models/global-config.ts index bd1976f..172f182 100644 --- a/src/core/models/global-config.ts +++ b/src/core/models/global-config.ts @@ -63,6 +63,8 @@ export interface GlobalConfig { pieceCategoriesFile?: string; /** Branch name generation strategy: 'romaji' (fast, default) or 'ai' (slow) */ branchNameStrategy?: 'romaji' | 'ai'; + /** Prevent macOS idle sleep during takt execution using caffeinate (default: false) */ + preventSleep?: boolean; } /** Project-level configuration */ diff --git a/src/core/models/schemas.ts b/src/core/models/schemas.ts index c3645b0..d9ccda5 100644 --- a/src/core/models/schemas.ts +++ b/src/core/models/schemas.ts @@ -272,6 +272,8 @@ export const GlobalConfigSchema = z.object({ piece_categories_file: z.string().optional(), /** Branch name generation strategy: 'romaji' (fast, default) or 'ai' (slow) */ branch_name_strategy: z.enum(['romaji', 'ai']).optional(), + /** Prevent macOS idle sleep during takt execution using caffeinate (default: false) */ + prevent_sleep: z.boolean().optional(), }); /** Project config schema */ diff --git a/src/features/tasks/execute/pieceExecution.ts b/src/features/tasks/execute/pieceExecution.ts index d39790a..0ff8119 100644 --- a/src/features/tasks/execute/pieceExecution.ts +++ b/src/features/tasks/execute/pieceExecution.ts @@ -46,7 +46,7 @@ import { type NdjsonInteractiveStart, type NdjsonInteractiveEnd, } from '../../../infra/fs/index.js'; -import { createLogger, notifySuccess, notifyError } from '../../../shared/utils/index.js'; +import { createLogger, notifySuccess, notifyError, preventSleep } from '../../../shared/utils/index.js'; import { selectOption, promptInput } from '../../../shared/prompt/index.js'; import { EXIT_SIGINT } from '../../../shared/exitCodes.js'; import { getLabel } from '../../../shared/i18n/index.js'; @@ -141,7 +141,13 @@ export async function executePiece( // Load saved agent sessions for continuity (from project root or clone-specific storage) const isWorktree = cwd !== projectCwd; - const currentProvider = loadGlobalConfig().provider ?? 'claude'; + const globalConfig = loadGlobalConfig(); + const currentProvider = globalConfig.provider ?? 'claude'; + + // Prevent macOS idle sleep if configured + if (globalConfig.preventSleep) { + preventSleep(); + } const savedSessions = isWorktree ? loadWorktreeSessions(projectCwd, cwd, currentProvider) : loadAgentSessions(projectCwd, currentProvider); diff --git a/src/infra/config/global/globalConfig.ts b/src/infra/config/global/globalConfig.ts index 6597066..d6d4cdc 100644 --- a/src/infra/config/global/globalConfig.ts +++ b/src/infra/config/global/globalConfig.ts @@ -89,6 +89,7 @@ export class GlobalConfigManager { bookmarksFile: parsed.bookmarks_file, pieceCategoriesFile: parsed.piece_categories_file, branchNameStrategy: parsed.branch_name_strategy, + preventSleep: parsed.prevent_sleep, }; this.cachedConfig = config; return config; @@ -151,6 +152,9 @@ export class GlobalConfigManager { if (config.branchNameStrategy) { raw.branch_name_strategy = config.branchNameStrategy; } + if (config.preventSleep !== undefined) { + raw.prevent_sleep = config.preventSleep; + } writeFileSync(configPath, stringifyYaml(raw), 'utf-8'); this.invalidateCache(); } diff --git a/src/shared/utils/index.ts b/src/shared/utils/index.ts index 1f49ef9..1eb7ab4 100644 --- a/src/shared/utils/index.ts +++ b/src/shared/utils/index.ts @@ -6,6 +6,7 @@ export * from './debug.js'; export * from './error.js'; export * from './notification.js'; export * from './reportDir.js'; +export * from './sleep.js'; export * from './slug.js'; export * from './text.js'; export * from './types.js'; diff --git a/src/shared/utils/sleep.ts b/src/shared/utils/sleep.ts new file mode 100644 index 0000000..0c2ec6d --- /dev/null +++ b/src/shared/utils/sleep.ts @@ -0,0 +1,46 @@ +import { spawn } from 'node:child_process'; +import { platform } from 'node:os'; +import { existsSync } from 'node:fs'; +import { createLogger } from './debug.js'; + +const log = createLogger('sleep'); + +let caffeinateStarted = false; + +/** + * takt実行中のmacOSアイドルスリープを防止する。 + * 蓋を閉じた場合のスリープは防げない(-s はAC電源が必要なため)。 + */ +export function preventSleep(): void { + if (caffeinateStarted) { + return; + } + + if (platform() !== 'darwin') { + return; + } + + const caffeinatePath = '/usr/bin/caffeinate'; + if (!existsSync(caffeinatePath)) { + log.info('caffeinate not found, sleep prevention disabled'); + return; + } + + const child = spawn(caffeinatePath, ['-i', '-w', String(process.pid)], { + stdio: 'ignore', + detached: true, + }); + + child.unref(); + + caffeinateStarted = true; + + log.debug('Started caffeinate for sleep prevention', { pid: child.pid }); +} + +/** + * テスト用: caffeinateStarted フラグをリセットする + */ +export function resetPreventSleepState(): void { + caffeinateStarted = false; +}