diff --git a/src/__tests__/taskSyncAction.test.ts b/src/__tests__/taskSyncAction.test.ts index e0bb695..ea45049 100644 --- a/src/__tests__/taskSyncAction.test.ts +++ b/src/__tests__/taskSyncAction.test.ts @@ -11,6 +11,9 @@ vi.mock('node:child_process', () => ({ vi.mock('../shared/ui/index.js', () => ({ success: vi.fn(), error: vi.fn(), + StreamDisplay: vi.fn(() => ({ + createHandler: vi.fn(() => vi.fn()), + })), })); vi.mock('../shared/utils/index.js', () => ({ @@ -22,37 +25,52 @@ vi.mock('../shared/utils/index.js', () => ({ getErrorMessage: vi.fn((err) => String(err)), })); -vi.mock('../features/tasks/execute/taskExecution.js', () => ({ - executeTask: vi.fn(), +const mockAgentCall = vi.fn(); + +vi.mock('../infra/providers/index.js', () => ({ + getProvider: vi.fn(() => ({ + setup: vi.fn(() => ({ call: mockAgentCall })), + })), })); -vi.mock('../features/tasks/execute/selectAndExecute.js', () => ({ - determinePiece: vi.fn(), +vi.mock('../infra/config/index.js', () => ({ + getLanguage: vi.fn(() => 'en'), + resolveConfigValues: vi.fn(() => ({ provider: 'claude', model: 'sonnet' })), })); -vi.mock('../shared/constants.js', () => ({ - DEFAULT_PIECE_NAME: 'default', +vi.mock('../infra/github/index.js', () => ({ + pushBranch: vi.fn(), +})); + +vi.mock('../shared/prompts/index.js', () => ({ + loadTemplate: vi.fn((_name: string, _lang: string, vars?: Record) => { + if (_name === 'sync_conflict_resolver_system_prompt') return 'system-prompt'; + if (_name === 'sync_conflict_resolver_message') return `message:${vars?.originalInstruction ?? ''}`; + return ''; + }), })); import * as fs from 'node:fs'; import { execFileSync } from 'node:child_process'; import { error as logError, success } from '../shared/ui/index.js'; -import { executeTask } from '../features/tasks/execute/taskExecution.js'; -import { determinePiece } from '../features/tasks/execute/selectAndExecute.js'; +import { pushBranch } from '../infra/github/index.js'; +import { getProvider } from '../infra/providers/index.js'; import { syncBranchWithRoot } from '../features/tasks/list/taskSyncAction.js'; import type { TaskListItem } from '../infra/task/index.js'; +import type { AgentResponse } from '../core/models/index.js'; const mockExistsSync = vi.mocked(fs.existsSync); const mockExecFileSync = vi.mocked(execFileSync); -const mockExecuteTask = vi.mocked(executeTask); -const mockDeterminePiece = vi.mocked(determinePiece); const mockLogError = vi.mocked(logError); const mockSuccess = vi.mocked(success); +const mockPushBranch = vi.mocked(pushBranch); +const mockGetProvider = vi.mocked(getProvider); function makeTask(overrides: Partial = {}): TaskListItem { return { kind: 'completed', name: 'test-task', + branch: 'task/test-task', createdAt: '2026-01-01T00:00:00Z', filePath: '/project/.takt/tasks.yaml', content: 'Implement feature X', @@ -61,13 +79,23 @@ function makeTask(overrides: Partial = {}): TaskListItem { }; } +function makeAgentResponse(overrides: Partial = {}): AgentResponse { + return { + persona: 'conflict-resolver', + status: 'done', + content: 'Conflicts resolved', + timestamp: new Date(), + ...overrides, + }; +} + const PROJECT_DIR = '/project'; describe('syncBranchWithRoot', () => { beforeEach(() => { vi.clearAllMocks(); mockExistsSync.mockReturnValue(true); - mockDeterminePiece.mockResolvedValue('default'); + mockAgentCall.mockResolvedValue(makeAgentResponse()); }); it('throws when called with a non-task BranchActionTarget', async () => { @@ -113,90 +141,57 @@ describe('syncBranchWithRoot', () => { expect(result).toBe(false); expect(mockLogError).toHaveBeenCalledWith(expect.stringContaining('Failed to fetch from root')); - expect(mockExecuteTask).not.toHaveBeenCalled(); + expect(mockAgentCall).not.toHaveBeenCalled(); }); - it('returns true and shows "Synced." when merge succeeds without conflicts', async () => { + it('returns true and pushes when merge succeeds without conflicts', async () => { const task = makeTask(); mockExecFileSync.mockReturnValue('' as never); const result = await syncBranchWithRoot(PROJECT_DIR, task); expect(result).toBe(true); - expect(mockSuccess).toHaveBeenCalledWith('Synced.'); - expect(mockExecuteTask).not.toHaveBeenCalled(); + expect(mockSuccess).toHaveBeenCalledWith('Synced & pushed.'); + expect(mockAgentCall).not.toHaveBeenCalled(); + // worktree → project push + expect(mockExecFileSync).toHaveBeenCalledWith( + 'git', ['push', PROJECT_DIR, 'HEAD'], + expect.objectContaining({ cwd: task.worktreePath }), + ); + // project → origin push + expect(mockPushBranch).toHaveBeenCalledWith(PROJECT_DIR, 'task/test-task'); }); - it('calls executeTask with conflict resolution instruction when merge has conflicts', async () => { + it('calls provider agent when merge has conflicts', async () => { const task = makeTask(); mockExecFileSync .mockReturnValueOnce('' as never) .mockImplementationOnce(() => { throw new Error('CONFLICT'); }); - mockExecuteTask.mockResolvedValue(true); - const result = await syncBranchWithRoot(PROJECT_DIR, task); expect(result).toBe(true); - expect(mockSuccess).toHaveBeenCalledWith('Conflicts resolved.'); - expect(mockExecuteTask).toHaveBeenCalledWith( + expect(mockSuccess).toHaveBeenCalledWith('Conflicts resolved & pushed.'); + expect(mockGetProvider).toHaveBeenCalledWith('claude'); + expect(mockAgentCall).toHaveBeenCalledWith( + expect.stringContaining('Implement feature X'), expect.objectContaining({ cwd: task.worktreePath, - projectCwd: PROJECT_DIR, - pieceIdentifier: 'default', - task: expect.stringContaining('Git merge has stopped due to merge conflicts.'), + model: 'sonnet', + permissionMode: 'edit', + onPermissionRequest: expect.any(Function), + onStream: expect.any(Function), }), ); }); - it('includes original task content in conflict resolution instruction', async () => { - const task = makeTask({ content: 'Implement feature X' }); - mockExecFileSync - .mockReturnValueOnce('' as never) - .mockImplementationOnce(() => { throw new Error('CONFLICT'); }); - mockExecuteTask.mockResolvedValue(true); - - await syncBranchWithRoot(PROJECT_DIR, task); - - expect(mockExecuteTask).toHaveBeenCalledWith( - expect.objectContaining({ - task: expect.stringContaining('Implement feature X'), - }), - ); - }); - - it('uses task piece when available for AI resolution', async () => { - const task = makeTask({ data: { piece: 'custom-piece' } }); - mockExecFileSync - .mockReturnValueOnce('' as never) - .mockImplementationOnce(() => { throw new Error('CONFLICT'); }); - mockDeterminePiece.mockResolvedValue('custom-piece'); - mockExecuteTask.mockResolvedValue(true); - - await syncBranchWithRoot(PROJECT_DIR, task); - - expect(mockDeterminePiece).toHaveBeenCalledWith(PROJECT_DIR, 'custom-piece'); - }); - - it('uses DEFAULT_PIECE_NAME when task.data.piece is not set', async () => { - const task = makeTask({ data: undefined }); - mockExecFileSync - .mockReturnValueOnce('' as never) - .mockImplementationOnce(() => { throw new Error('CONFLICT'); }); - mockExecuteTask.mockResolvedValue(true); - - await syncBranchWithRoot(PROJECT_DIR, task); - - expect(mockDeterminePiece).toHaveBeenCalledWith(PROJECT_DIR, 'default'); - }); - it('aborts merge and returns false when AI resolution fails', async () => { const task = makeTask(); mockExecFileSync .mockReturnValueOnce('' as never) .mockImplementationOnce(() => { throw new Error('CONFLICT'); }) .mockReturnValueOnce('' as never); - mockExecuteTask.mockResolvedValue(false); + mockAgentCall.mockResolvedValue(makeAgentResponse({ status: 'error' })); const result = await syncBranchWithRoot(PROJECT_DIR, task); @@ -210,31 +205,13 @@ describe('syncBranchWithRoot', () => { ); }); - it('aborts merge and returns false when determinePiece returns null', async () => { - const task = makeTask(); - mockExecFileSync - .mockReturnValueOnce('' as never) - .mockImplementationOnce(() => { throw new Error('CONFLICT'); }) - .mockReturnValueOnce('' as never); - mockDeterminePiece.mockResolvedValue(null); - - const result = await syncBranchWithRoot(PROJECT_DIR, task); - - expect(result).toBe(false); - expect(mockExecuteTask).not.toHaveBeenCalled(); - expect(mockExecFileSync).toHaveBeenCalledWith( - 'git', ['merge', '--abort'], - expect.objectContaining({ cwd: task.worktreePath }), - ); - }); - it('does not throw when git merge --abort itself fails', async () => { const task = makeTask(); mockExecFileSync .mockReturnValueOnce('' as never) .mockImplementationOnce(() => { throw new Error('CONFLICT'); }) .mockImplementationOnce(() => { throw new Error('abort failed'); }); - mockDeterminePiece.mockResolvedValue(null); + mockAgentCall.mockResolvedValue(makeAgentResponse({ status: 'error' })); const result = await syncBranchWithRoot(PROJECT_DIR, task); @@ -253,19 +230,4 @@ describe('syncBranchWithRoot', () => { expect.objectContaining({ cwd: task.worktreePath }), ); }); - - it('passes agentOverrides to executeTask', async () => { - const task = makeTask(); - mockExecFileSync - .mockReturnValueOnce('' as never) - .mockImplementationOnce(() => { throw new Error('CONFLICT'); }); - mockExecuteTask.mockResolvedValue(true); - const options = { provider: 'anthropic' as never }; - - await syncBranchWithRoot(PROJECT_DIR, task, options); - - expect(mockExecuteTask).toHaveBeenCalledWith( - expect.objectContaining({ agentOverrides: options }), - ); - }); }); diff --git a/src/features/tasks/list/index.ts b/src/features/tasks/list/index.ts index d01c680..cedfe04 100644 --- a/src/features/tasks/list/index.ts +++ b/src/features/tasks/list/index.ts @@ -169,7 +169,7 @@ export async function listTasks( await instructBranch(cwd, task); break; case 'sync': - await syncBranchWithRoot(cwd, task, options); + await syncBranchWithRoot(cwd, task); break; case 'try': tryMergeBranch(cwd, task); diff --git a/src/features/tasks/list/taskSyncAction.ts b/src/features/tasks/list/taskSyncAction.ts index ec14f4f..6225c7f 100644 --- a/src/features/tasks/list/taskSyncAction.ts +++ b/src/features/tasks/list/taskSyncAction.ts @@ -1,36 +1,21 @@ import * as fs from 'node:fs'; import { execFileSync } from 'node:child_process'; -import { success, error as logError } from '../../../shared/ui/index.js'; +import { success, error as logError, StreamDisplay } from '../../../shared/ui/index.js'; import { createLogger, getErrorMessage } from '../../../shared/utils/index.js'; -import { executeTask } from '../execute/taskExecution.js'; -import { determinePiece } from '../execute/selectAndExecute.js'; -import { DEFAULT_PIECE_NAME } from '../../../shared/constants.js'; -import { type BranchActionTarget, resolveTargetInstruction } from './taskActionTarget.js'; -import type { TaskExecutionOptions } from '../execute/types.js'; +import { getProvider, type ProviderType } from '../../../infra/providers/index.js'; +import { resolveConfigValues } from '../../../infra/config/index.js'; +import { pushBranch } from '../../../infra/github/index.js'; +import { loadTemplate } from '../../../shared/prompts/index.js'; +import { getLanguage } from '../../../infra/config/index.js'; +import { type BranchActionTarget, resolveTargetBranch, resolveTargetInstruction } from './taskActionTarget.js'; const log = createLogger('list-tasks'); const SYNC_REF = 'refs/remotes/root/sync-target'; -function buildConflictResolutionInstruction(originalInstruction: string): string { - return `Git merge has stopped due to merge conflicts. - -Resolve all conflicts to complete the merge: -1. Run \`git status\` to identify conflicted files -2. For each conflicted file, resolve the conflict markers - (<<<<<<< HEAD / ======= / >>>>>>> lines) - Preserve changes that align with the original task intent -3. Stage each resolved file: \`git add \` -4. Complete the merge: \`git commit\` - -Original task: -${originalInstruction}`; -} - export async function syncBranchWithRoot( projectDir: string, target: BranchActionTarget, - options?: TaskExecutionOptions, ): Promise { if (!('kind' in target)) { throw new Error('Sync requires a task target.'); @@ -57,41 +42,49 @@ export async function syncBranchWithRoot( return false; } + let mergeConflict = false; try { execFileSync('git', ['merge', SYNC_REF], { cwd: worktreePath, encoding: 'utf-8', stdio: 'pipe', }); - success('Synced.'); - log.info('Merge succeeded without conflicts', { worktreePath }); - return true; } catch (err) { + mergeConflict = true; log.info('Merge conflict detected, attempting AI resolution', { worktreePath, error: getErrorMessage(err), }); } - const pieceIdentifier = await determinePiece(projectDir, target.data?.piece ?? DEFAULT_PIECE_NAME); - if (!pieceIdentifier) { - abortMerge(worktreePath); - return false; + if (!mergeConflict) { + pushSynced(worktreePath, projectDir, target); + success('Synced & pushed.'); + log.info('Merge succeeded without conflicts', { worktreePath }); + return true; } + const lang = getLanguage(); const originalInstruction = resolveTargetInstruction(target); - const conflictInstruction = buildConflictResolutionInstruction(originalInstruction); + const systemPrompt = loadTemplate('sync_conflict_resolver_system_prompt', lang); + const prompt = loadTemplate('sync_conflict_resolver_message', lang, { originalInstruction }); - const aiSuccess = await executeTask({ - task: conflictInstruction, + const config = resolveConfigValues(projectDir, ['provider', 'model']); + const providerType = (config.provider ?? 'claude') as ProviderType; + const provider = getProvider(providerType); + const agent = provider.setup({ name: 'conflict-resolver', systemPrompt }); + + const response = await agent.call(prompt, { cwd: worktreePath, - pieceIdentifier, - projectCwd: projectDir, - agentOverrides: options, + model: config.model, + permissionMode: 'edit', + onPermissionRequest: autoApproveBash, + onStream: new StreamDisplay('conflict-resolver', false).createHandler(), }); - if (aiSuccess) { - success('Conflicts resolved.'); + if (response.status === 'done') { + pushSynced(worktreePath, projectDir, target); + success('Conflicts resolved & pushed.'); log.info('AI conflict resolution succeeded', { worktreePath }); return true; } @@ -101,6 +94,25 @@ export async function syncBranchWithRoot( return false; } +/** Auto-approve all tool invocations (agent runs in isolated worktree) */ +async function autoApproveBash(request: { toolName: string; input: Record }) { + return { behavior: 'allow' as const, updatedInput: request.input }; +} + +/** Push worktree → project dir, then project dir → origin */ +function pushSynced(worktreePath: string, projectDir: string, target: BranchActionTarget): void { + execFileSync('git', ['push', projectDir, 'HEAD'], { + cwd: worktreePath, + encoding: 'utf-8', + stdio: 'pipe', + }); + log.info('Pushed to main repo', { worktreePath, projectDir }); + + const branch = resolveTargetBranch(target); + pushBranch(projectDir, branch); + log.info('Pushed to origin', { projectDir, branch }); +} + function abortMerge(worktreePath: string): void { try { execFileSync('git', ['merge', '--abort'], { @@ -110,6 +122,7 @@ function abortMerge(worktreePath: string): void { }); log.info('git merge --abort completed', { worktreePath }); } catch (err) { + logError(`Failed to abort merge: ${getErrorMessage(err)}`); log.error('git merge --abort failed', { worktreePath, error: getErrorMessage(err) }); } } diff --git a/src/shared/prompts/en/sync_conflict_resolver_message.md b/src/shared/prompts/en/sync_conflict_resolver_message.md new file mode 100644 index 0000000..09c8979 --- /dev/null +++ b/src/shared/prompts/en/sync_conflict_resolver_message.md @@ -0,0 +1,51 @@ + +Git merge has stopped due to merge conflicts. + +## Procedure + +### 1. Identify conflicts + +Run `git status` to list unmerged files. + +### 2. Understand context + +Run these in parallel: +- `git log --oneline HEAD -5` to see recent HEAD changes +- `git log --oneline MERGE_HEAD -5` to see incoming changes (if merge) + +### 3. Analyze each conflicted file + +For each file: +1. Read the full file (with conflict markers) +2. For each conflict block (`<<<<<<<` to `>>>>>>>`): + - Read HEAD side content + - Read theirs side content + - Determine what the diff means (version bump? refactor? feature addition?) + - If unclear, check `git log --oneline -- {file}` +3. Write your judgment before resolving + +### 4. Resolve + +- If one side is clearly correct: `git checkout --ours {file}` or `git checkout --theirs {file}` +- If both changes need merging: edit the file to combine both sides +- Stage each resolved file: `git add {file}` + +After resolving, search for `<<<<<<<` to ensure no markers remain. + +### 5. Verify + +- Run build/test if available +- Check that non-conflicted files are consistent with the resolution + +### 6. Complete the merge + +Run `git commit` to finalize. + +## Original task + +{{originalInstruction}} diff --git a/src/shared/prompts/en/sync_conflict_resolver_system_prompt.md b/src/shared/prompts/en/sync_conflict_resolver_system_prompt.md new file mode 100644 index 0000000..75f1131 --- /dev/null +++ b/src/shared/prompts/en/sync_conflict_resolver_system_prompt.md @@ -0,0 +1,23 @@ + +You are a git merge conflict resolver. + +Your only job is to resolve merge conflicts and complete the merge commit. +Do not refactor, improve, or change any code beyond what is necessary to resolve conflicts. + +## Principles + +- **Read diffs before resolving.** Never apply `--ours` / `--theirs` without inspecting file contents. +- **Do not blindly favor one side.** Even if one branch is "newer", check whether the other side has intentional changes. +- **Document your reasoning.** For each conflict, note what each side contains and why you chose the resolution. +- **Verify ripple effects.** After resolving, check that non-conflicted files are still consistent. + +## Prohibited + +- Resolving all files with `git checkout --ours .` or `git checkout --theirs .` without analysis +- Leaving conflict markers (`<<<<<<<`) in any file +- Running `git merge --abort` without user confirmation diff --git a/src/shared/prompts/ja/sync_conflict_resolver_message.md b/src/shared/prompts/ja/sync_conflict_resolver_message.md new file mode 100644 index 0000000..536042a --- /dev/null +++ b/src/shared/prompts/ja/sync_conflict_resolver_message.md @@ -0,0 +1,51 @@ + +Git merge がコンフリクトにより停止しました。 + +## 手順 + +### 1. コンフリクト状態を確認する + +`git status` を実行して未マージファイルを列挙する。 + +### 2. コンテキストを把握する + +以下を並列で実行: +- `git log --oneline HEAD -5` で HEAD 側の最近の変更を確認 +- `git log --oneline MERGE_HEAD -5` で取り込み側の最近の変更を確認(merge の場合) + +### 3. 各ファイルを分析する + +ファイルごとに以下を実行: +1. ファイル全体を読む(コンフリクトマーカー付きの状態) +2. 各コンフリクトブロック(`<<<<<<<` 〜 `>>>>>>>`)について: + - HEAD 側の内容を具体的に読む + - theirs 側の内容を具体的に読む + - 差分が何を意味するか分析する(バージョン番号?リファクタ?機能追加?) + - 判断に迷う場合は `git log --oneline -- {file}` で変更履歴を確認する +3. 解決前に判断根拠を記述する + +### 4. 解決を実施する + +- 片方採用が明確な場合: `git checkout --ours {file}` / `git checkout --theirs {file}` +- 両方の変更を統合する場合: ファイルを編集してコンフリクトマーカーを除去し、両方の内容を結合する +- 解決したファイルを `git add {file}` でマークする + +解決後、`<<<<<<<` を検索してマーカーの取り残しがないか確認する。 + +### 5. 波及影響を確認する + +- ビルド・テストが利用可能なら実行する +- コンフリクト対象外のファイルが、解決した変更と矛盾していないか確認する + +### 6. マージを完了する + +`git commit` を実行して完了する。 + +## 元のタスク + +{{originalInstruction}} diff --git a/src/shared/prompts/ja/sync_conflict_resolver_system_prompt.md b/src/shared/prompts/ja/sync_conflict_resolver_system_prompt.md new file mode 100644 index 0000000..222b511 --- /dev/null +++ b/src/shared/prompts/ja/sync_conflict_resolver_system_prompt.md @@ -0,0 +1,24 @@ + +あなたは git merge コンフリクトの解決担当です。 + +コンフリクトを解決してマージコミットを完了することだけが仕事です。 +コンフリクト解決に必要な範囲を超えて、コードのリファクタリングや改善を行わないでください。 + +## 原則 + +- **差分を読まずに解決しない。** ファイルの中身を確認せずに `--ours` / `--theirs` を適用しない +- **「〇〇優先」を盲従しない。** 片方が新しいからという理由だけで判断せず、もう一方に独自の意図がないか必ず確認する +- **判断根拠を省略しない。** 各コンフリクトに「何が・なぜ・どちらを」の3点を書く +- **波及を確認する。** コンフリクト対象外ファイルもビルド・テストで検証する + +## 禁止事項 + +- 分析なしで `git checkout --ours .` / `git checkout --theirs .` を実行しない +- 「とりあえず片方」で全ファイルを一括解決しない +- コンフリクトマーカー (`<<<<<<<`) が残ったままにしない +- `git merge --abort` をユーザーの確認なしに実行しない