diff --git a/package.json b/package.json index 4cb1776..4c02de3 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "test:e2e:codex": "npm run test:e2e:provider:codex", "test:e2e:opencode": "npm run test:e2e:provider:opencode", "lint": "eslint src/", + "check:release": "npm run build && npm run lint && npm run test && npm run test:e2e", "prepublishOnly": "npm run lint && npm run build && npm run test" }, "keywords": [ diff --git a/src/__tests__/cli-worktree.test.ts b/src/__tests__/cli-worktree.test.ts index a8fbfa5..24fc270 100644 --- a/src/__tests__/cli-worktree.test.ts +++ b/src/__tests__/cli-worktree.test.ts @@ -62,7 +62,6 @@ vi.mock('../infra/config/index.js', () => ({ initGlobalDirs: vi.fn(), initProjectDirs: vi.fn(), loadGlobalConfig: vi.fn(() => ({ logLevel: 'info' })), - getEffectiveDebugConfig: vi.fn(), })); vi.mock('../infra/config/paths.js', () => ({ diff --git a/src/__tests__/instructMode.test.ts b/src/__tests__/instructMode.test.ts index b0d9605..5ebb1a3 100644 --- a/src/__tests__/instructMode.test.ts +++ b/src/__tests__/instructMode.test.ts @@ -103,29 +103,17 @@ describe('runInstructMode', () => { setupRawStdin(toRawInputs(['/cancel'])); setupMockProvider([]); - const result = await runInstructMode('/project', 'branch context', 'feature-branch'); + const result = await runInstructMode('/project', 'branch context', 'feature-branch', 'my-task', 'Do something', ''); expect(result.action).toBe('cancel'); expect(result.task).toBe(''); }); - it('should include branch name in intro message', async () => { - setupRawStdin(toRawInputs(['/cancel'])); - setupMockProvider([]); - - await runInstructMode('/project', 'diff stats', 'my-feature-branch'); - - const introCall = mockInfo.mock.calls.find((call) => - call[0]?.includes('my-feature-branch') - ); - expect(introCall).toBeDefined(); - }); - it('should return action=execute with task on /go after conversation', async () => { setupRawStdin(toRawInputs(['add more tests', '/go'])); setupMockProvider(['What kind of tests?', 'Add unit tests for the feature.']); - const result = await runInstructMode('/project', 'branch context', 'feature-branch'); + const result = await runInstructMode('/project', 'branch context', 'feature-branch', 'my-task', 'Do something', ''); expect(result.action).toBe('execute'); expect(result.task).toBe('Add unit tests for the feature.'); @@ -136,7 +124,7 @@ describe('runInstructMode', () => { setupMockProvider(['response', 'Summarized task.']); mockSelectOption.mockResolvedValue('save_task'); - const result = await runInstructMode('/project', 'branch context', 'feature-branch'); + const result = await runInstructMode('/project', 'branch context', 'feature-branch', 'my-task', 'Do something', ''); expect(result.action).toBe('save_task'); expect(result.task).toBe('Summarized task.'); @@ -147,7 +135,7 @@ describe('runInstructMode', () => { setupMockProvider(['response', 'Summarized task.']); mockSelectOption.mockResolvedValueOnce('continue'); - const result = await runInstructMode('/project', 'branch context', 'feature-branch'); + const result = await runInstructMode('/project', 'branch context', 'feature-branch', 'my-task', 'Do something', ''); expect(result.action).toBe('cancel'); }); @@ -156,7 +144,7 @@ describe('runInstructMode', () => { setupRawStdin(toRawInputs(['/go', '/cancel'])); setupMockProvider([]); - const result = await runInstructMode('/project', 'branch context', 'feature-branch'); + const result = await runInstructMode('/project', 'branch context', 'feature-branch', 'my-task', 'Do something', ''); expect(result.action).toBe('cancel'); }); @@ -165,7 +153,7 @@ describe('runInstructMode', () => { setupRawStdin(toRawInputs(['task', '/go'])); setupMockProvider(['response', 'Task summary.']); - await runInstructMode('/project', 'branch context', 'feature-branch'); + await runInstructMode('/project', 'branch context', 'feature-branch', 'my-task', 'Do something', ''); const selectCall = mockSelectOption.mock.calls.find((call) => Array.isArray(call[1]) @@ -179,6 +167,25 @@ describe('runInstructMode', () => { expect(values).not.toContain('create_issue'); }); + it('should use dedicated instruct system prompt with task context', async () => { + setupRawStdin(toRawInputs(['/cancel'])); + setupMockProvider([]); + + await runInstructMode('/project', 'branch context', 'feature-branch', 'my-task', 'Do something', 'existing note'); + + expect(mockLoadTemplate).toHaveBeenCalledWith( + 'score_instruct_system_prompt', + 'en', + expect.objectContaining({ + taskName: 'my-task', + taskContent: 'Do something', + branchName: 'feature-branch', + branchContext: 'branch context', + retryNote: 'existing note', + }), + ); + }); + it('should inject selected run context into system prompt variables', async () => { setupRawStdin(toRawInputs(['/cancel'])); setupMockProvider([]); @@ -195,10 +202,10 @@ describe('runInstructMode', () => { ], }; - await runInstructMode('/project', 'branch context', 'feature-branch', undefined, runSessionContext); + await runInstructMode('/project', 'branch context', 'feature-branch', 'my-task', 'Do something', '', undefined, runSessionContext); expect(mockLoadTemplate).toHaveBeenCalledWith( - 'score_interactive_system_prompt', + 'score_instruct_system_prompt', 'en', expect.objectContaining({ hasRunSession: true, diff --git a/src/__tests__/it-interactive-routes.test.ts b/src/__tests__/it-interactive-routes.test.ts index 52bfb73..a7745d6 100644 --- a/src/__tests__/it-interactive-routes.test.ts +++ b/src/__tests__/it-interactive-routes.test.ts @@ -104,7 +104,7 @@ function setupScenarioProvider(...scenarios: Parameters { @@ -394,7 +394,7 @@ describe('policy injection', () => { setupRawStdin(toRawInputs(['fix the bug', '/cancel'])); const capture = setupProvider(['OK.']); - await runInstructMode('/test', '', 'takt/test'); + await runInstructMode('/test', '', 'takt/test', 'test', '', ''); // The prompt sent to AI should contain Policy section expect(capture.prompts[0]).toContain('Policy'); @@ -407,21 +407,22 @@ describe('policy injection', () => { // System prompt: branch name appears in intro // ================================================================= describe('branch context', () => { - it('should include branch name and context in intro', async () => { - setupRawStdin(toRawInputs(['/cancel'])); - setupProvider([]); - - const { info: mockInfo } = await import('../shared/ui/index.js'); + it('should include branch name and context in system prompt', async () => { + setupRawStdin(toRawInputs(['check changes', '/cancel'])); + const capture = setupProvider(['Looks good.']); await runInstructMode( '/test', '## Changes\n```\nsrc/auth.ts | 50 +++\n```', 'takt/feature-auth', + 'feature-auth', + 'Do something', + '', ); - const introCall = vi.mocked(mockInfo).mock.calls.find((call) => - call[0]?.includes('takt/feature-auth'), - ); - expect(introCall).toBeDefined(); + expect(capture.systemPrompts.length).toBeGreaterThan(0); + const systemPrompt = capture.systemPrompts[0]!; + expect(systemPrompt).toContain('takt/feature-auth'); + expect(systemPrompt).toContain('src/auth.ts | 50 +++'); }); }); diff --git a/src/__tests__/it-retry-mode.test.ts b/src/__tests__/it-retry-mode.test.ts new file mode 100644 index 0000000..bd4cac7 --- /dev/null +++ b/src/__tests__/it-retry-mode.test.ts @@ -0,0 +1,410 @@ +/** + * E2E test: Retry mode with failure context and run session injection. + * + * Simulates the retry assistant flow: + * 1. Create .takt/runs/ fixtures (logs, reports) + * 2. Build RetryContext with failure info + run session + * 3. Run retry mode with stdin simulation (user types message → /go) + * 4. Mock provider captures the system prompt sent to AI + * 5. Verify failure info AND run session data appear in the system prompt + * + * Real: buildRetryTemplateVars, loadTemplate, runConversationLoop, + * loadRunSessionContext, formatRunSessionForPrompt, getRunPaths + * Mocked: provider (captures system prompt), config, UI, session persistence + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { mkdirSync, writeFileSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { + setupRawStdin, + restoreStdin, + toRawInputs, + createMockProvider, + type MockProviderCapture, +} from './helpers/stdinSimulator.js'; + +// --- Mocks (infrastructure only) --- + +vi.mock('../infra/fs/session.js', () => ({ + loadNdjsonLog: vi.fn(), +})); + +vi.mock('../infra/config/global/globalConfig.js', () => ({ + loadGlobalConfig: vi.fn(() => ({ provider: 'mock', language: 'en' })), + getBuiltinPiecesEnabled: vi.fn().mockReturnValue(true), +})); + +vi.mock('../infra/providers/index.js', () => ({ + getProvider: vi.fn(), +})); + +vi.mock('../shared/utils/index.js', async (importOriginal) => ({ + ...(await importOriginal>()), + createLogger: () => ({ + info: vi.fn(), + debug: vi.fn(), + error: vi.fn(), + }), +})); + +vi.mock('../shared/context.js', () => ({ + isQuietMode: vi.fn(() => false), +})); + +vi.mock('../infra/config/paths.js', async (importOriginal) => ({ + ...(await importOriginal>()), + loadPersonaSessions: vi.fn(() => ({})), + updatePersonaSession: vi.fn(), + getProjectConfigDir: vi.fn(() => '/tmp'), + loadSessionState: vi.fn(() => null), + clearSessionState: vi.fn(), +})); + +vi.mock('../shared/ui/index.js', () => ({ + info: vi.fn(), + error: vi.fn(), + blankLine: vi.fn(), + StreamDisplay: vi.fn().mockImplementation(() => ({ + createHandler: vi.fn(() => vi.fn()), + flush: vi.fn(), + })), +})); + +vi.mock('../shared/prompt/index.js', () => ({ + selectOption: vi.fn().mockResolvedValue('execute'), +})); + +vi.mock('../shared/i18n/index.js', () => ({ + getLabel: vi.fn((_key: string, _lang: string) => 'Mock label'), + getLabelObject: vi.fn(() => ({ + intro: 'Retry intro', + resume: 'Resume', + noConversation: 'No conversation', + summarizeFailed: 'Summarize failed', + continuePrompt: 'Continue?', + proposed: 'Proposed:', + actionPrompt: 'What next?', + playNoTask: 'No task', + cancelled: 'Cancelled', + actions: { execute: 'Execute', saveTask: 'Save', continue: 'Continue' }, + })), +})); + +// --- Imports (after mocks) --- + +import { getProvider } from '../infra/providers/index.js'; +import { loadNdjsonLog } from '../infra/fs/session.js'; +import { + loadRunSessionContext, + formatRunSessionForPrompt, + getRunPaths, +} from '../features/interactive/runSessionReader.js'; +import { runRetryMode, type RetryContext } from '../features/interactive/retryMode.js'; + +const mockGetProvider = vi.mocked(getProvider); +const mockLoadNdjsonLog = vi.mocked(loadNdjsonLog); + +// --- Fixture helpers --- + +function createTmpDir(): string { + const dir = join(tmpdir(), `takt-retry-e2e-${Date.now()}-${Math.random().toString(36).slice(2)}`); + mkdirSync(dir, { recursive: true }); + return dir; +} + +function createRunFixture( + cwd: string, + slug: string, + overrides?: { + meta?: Record; + reports?: Array<{ name: string; content: string }>; + }, +): void { + const runDir = join(cwd, '.takt', 'runs', slug); + mkdirSync(join(runDir, 'logs'), { recursive: true }); + mkdirSync(join(runDir, 'reports'), { recursive: true }); + + const meta = { + task: `Task for ${slug}`, + piece: 'default', + status: 'completed', + startTime: '2026-02-01T00:00:00.000Z', + logsDirectory: `.takt/runs/${slug}/logs`, + reportDirectory: `.takt/runs/${slug}/reports`, + runSlug: slug, + ...overrides?.meta, + }; + writeFileSync(join(runDir, 'meta.json'), JSON.stringify(meta), 'utf-8'); + writeFileSync(join(runDir, 'logs', 'session-001.jsonl'), '{}', 'utf-8'); + + for (const report of overrides?.reports ?? []) { + writeFileSync(join(runDir, 'reports', report.name), report.content, 'utf-8'); + } +} + +function setupMockNdjsonLog(history: Array<{ step: string; persona: string; status: string; content: string }>): void { + mockLoadNdjsonLog.mockReturnValue({ + task: 'mock', + projectDir: '', + pieceName: 'default', + iterations: history.length, + startTime: '2026-02-01T00:00:00.000Z', + status: 'completed', + history: history.map((h) => ({ + ...h, + instruction: '', + timestamp: '2026-02-01T00:00:00.000Z', + })), + }); +} + +function setupProvider(responses: string[]): MockProviderCapture { + const { provider, capture } = createMockProvider(responses); + mockGetProvider.mockReturnValue(provider); + return capture; +} + +// --- Tests --- + +describe('E2E: Retry mode with failure context injection', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = createTmpDir(); + vi.clearAllMocks(); + }); + + afterEach(() => { + restoreStdin(); + rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('should inject failure info into system prompt', async () => { + setupRawStdin(toRawInputs(['what went wrong?', '/go'])); + const capture = setupProvider([ + 'The review step failed due to a timeout.', + 'Fix review timeout by increasing the limit.', + ]); + + const retryContext: RetryContext = { + failure: { + taskName: 'implement-auth', + createdAt: '2026-02-15T10:00:00Z', + failedMovement: 'review', + error: 'Timeout after 300s', + lastMessage: 'Agent stopped responding', + retryNote: '', + }, + branchName: 'takt/implement-auth', + pieceContext: { + name: 'default', + description: '', + pieceStructure: '', + movementPreviews: [], + }, + run: null, + }; + + const result = await runRetryMode(tmpDir, retryContext); + + // Verify: system prompt contains failure information + expect(capture.systemPrompts.length).toBeGreaterThan(0); + const systemPrompt = capture.systemPrompts[0]!; + expect(systemPrompt).toContain('Retry Assistant'); + expect(systemPrompt).toContain('implement-auth'); + expect(systemPrompt).toContain('takt/implement-auth'); + expect(systemPrompt).toContain('review'); + expect(systemPrompt).toContain('Timeout after 300s'); + expect(systemPrompt).toContain('Agent stopped responding'); + + // Verify: flow completed + expect(result.action).toBe('execute'); + expect(result.task).toBe('Fix review timeout by increasing the limit.'); + expect(capture.callCount).toBe(2); + }); + + it('should inject failure info AND run session data into system prompt', async () => { + // Create run fixture with logs and reports + createRunFixture(tmpDir, 'run-failed', { + meta: { task: 'Build login page', status: 'failed' }, + reports: [ + { name: '00-plan.md', content: '# Plan\n\nLogin form with OAuth2.' }, + ], + }); + setupMockNdjsonLog([ + { step: 'plan', persona: 'architect', status: 'completed', content: 'Planned OAuth2 login flow' }, + { step: 'implement', persona: 'coder', status: 'failed', content: 'Failed at CSS compilation' }, + ]); + + // Load real run session data + const sessionContext = loadRunSessionContext(tmpDir, 'run-failed'); + const formatted = formatRunSessionForPrompt(sessionContext); + const paths = getRunPaths(tmpDir, 'run-failed'); + + setupRawStdin(toRawInputs(['fix the CSS issue', '/go'])); + const capture = setupProvider([ + 'The CSS compilation error is likely due to missing imports.', + 'Fix CSS imports in login component.', + ]); + + const retryContext: RetryContext = { + failure: { + taskName: 'build-login', + createdAt: '2026-02-15T14:00:00Z', + failedMovement: 'implement', + error: 'CSS compilation failed', + lastMessage: 'PostCSS error: unknown property', + retryNote: '', + }, + branchName: 'takt/build-login', + pieceContext: { + name: 'default', + description: '', + pieceStructure: '', + movementPreviews: [], + }, + run: { + logsDir: paths.logsDir, + reportsDir: paths.reportsDir, + task: formatted.runTask, + piece: formatted.runPiece, + status: formatted.runStatus, + movementLogs: formatted.runMovementLogs, + reports: formatted.runReports, + }, + }; + + const result = await runRetryMode(tmpDir, retryContext); + + // Verify: system prompt contains BOTH failure info and run session data + const systemPrompt = capture.systemPrompts[0]!; + + // Failure info + expect(systemPrompt).toContain('build-login'); + expect(systemPrompt).toContain('CSS compilation failed'); + expect(systemPrompt).toContain('PostCSS error: unknown property'); + expect(systemPrompt).toContain('implement'); + + // Run session data + expect(systemPrompt).toContain('Previous Run Data'); + expect(systemPrompt).toContain('Build login page'); + expect(systemPrompt).toContain('Planned OAuth2 login flow'); + expect(systemPrompt).toContain('Failed at CSS compilation'); + expect(systemPrompt).toContain('00-plan.md'); + expect(systemPrompt).toContain('Login form with OAuth2'); + + // Run paths (AI can use Read tool) + expect(systemPrompt).toContain(paths.logsDir); + expect(systemPrompt).toContain(paths.reportsDir); + + // Flow completed + expect(result.action).toBe('execute'); + expect(result.task).toBe('Fix CSS imports in login component.'); + }); + + it('should include existing retry note in system prompt', async () => { + setupRawStdin(toRawInputs(['what should I do?', '/go'])); + const capture = setupProvider([ + 'Based on the previous attempt, the mocks are still incomplete.', + 'Add complete mocks for all API endpoints.', + ]); + + const retryContext: RetryContext = { + failure: { + taskName: 'fix-tests', + createdAt: '2026-02-15T16:00:00Z', + failedMovement: '', + error: 'Test suite failed', + lastMessage: '', + retryNote: 'Previous attempt: added missing mocks but still failing', + }, + branchName: 'takt/fix-tests', + pieceContext: { + name: 'default', + description: '', + pieceStructure: '', + movementPreviews: [], + }, + run: null, + }; + + await runRetryMode(tmpDir, retryContext); + + const systemPrompt = capture.systemPrompts[0]!; + expect(systemPrompt).toContain('Existing Retry Note'); + expect(systemPrompt).toContain('Previous attempt: added missing mocks but still failing'); + + // absent fields should NOT appear as sections + expect(systemPrompt).not.toContain('Failed movement'); + expect(systemPrompt).not.toContain('Last Message'); + }); + + it('should cancel cleanly and not crash', async () => { + setupRawStdin(toRawInputs(['/cancel'])); + setupProvider([]); + + const retryContext: RetryContext = { + failure: { + taskName: 'some-task', + createdAt: '2026-02-15T12:00:00Z', + failedMovement: 'plan', + error: 'Unknown error', + lastMessage: '', + retryNote: '', + }, + branchName: 'takt/some-task', + pieceContext: { + name: 'default', + description: '', + pieceStructure: '', + movementPreviews: [], + }, + run: null, + }; + + const result = await runRetryMode(tmpDir, retryContext); + + expect(result.action).toBe('cancel'); + expect(result.task).toBe(''); + }); + + it('should handle conversation before /go with failure context', async () => { + setupRawStdin(toRawInputs([ + 'what was the error?', + 'can you suggest a fix?', + '/go', + ])); + const capture = setupProvider([ + 'The error was a timeout in the review step.', + 'You could increase the timeout limit or optimize the review.', + 'Increase review timeout to 600s and add retry logic.', + ]); + + const retryContext: RetryContext = { + failure: { + taskName: 'optimize-review', + createdAt: '2026-02-15T18:00:00Z', + failedMovement: 'review', + error: 'Timeout', + lastMessage: '', + retryNote: '', + }, + branchName: 'takt/optimize-review', + pieceContext: { + name: 'default', + description: '', + pieceStructure: '', + movementPreviews: [], + }, + run: null, + }; + + const result = await runRetryMode(tmpDir, retryContext); + + expect(result.action).toBe('execute'); + expect(result.task).toBe('Increase review timeout to 600s and add retry logic.'); + expect(capture.callCount).toBe(3); + }); +}); diff --git a/src/__tests__/it-run-session-instruct.test.ts b/src/__tests__/it-run-session-instruct.test.ts index b478029..d27d813 100644 --- a/src/__tests__/it-run-session-instruct.test.ts +++ b/src/__tests__/it-run-session-instruct.test.ts @@ -213,6 +213,9 @@ describe('E2E: Run session → instruct mode with interactive flow', () => { tmpDir, '## Branch: takt/fix-auth\n', 'takt/fix-auth', + 'fix-auth', + 'Implement JWT auth', + '', { name: 'default', description: '', pieceStructure: '', movementPreviews: [] }, context, ); @@ -239,7 +242,7 @@ describe('E2E: Run session → instruct mode with interactive flow', () => { setupRawStdin(toRawInputs(['/cancel'])); setupProvider([]); - const result = await runInstructMode(tmpDir, '', 'takt/fix', undefined, undefined); + const result = await runInstructMode(tmpDir, '', 'takt/fix', 'fix', '', '', undefined, undefined); expect(result.action).toBe('cancel'); }); @@ -254,7 +257,7 @@ describe('E2E: Run session → instruct mode with interactive flow', () => { const capture = setupProvider(['I understand.']); const result = await runInstructMode( - tmpDir, '', 'takt/branch', undefined, context, + tmpDir, '', 'takt/branch', 'branch', '', '', undefined, context, ); expect(result.action).toBe('cancel'); diff --git a/src/__tests__/prompts.test.ts b/src/__tests__/prompts.test.ts index 43785be..7c088ff 100644 --- a/src/__tests__/prompts.test.ts +++ b/src/__tests__/prompts.test.ts @@ -35,6 +35,16 @@ describe('loadTemplate', () => { expect(result).toContain('対話モードポリシー'); }); + it('loads an English retry system prompt template', () => { + const result = loadTemplate('score_retry_system_prompt', 'en'); + expect(result).toContain('Retry Assistant'); + }); + + it('loads a Japanese retry system prompt template', () => { + const result = loadTemplate('score_retry_system_prompt', 'ja'); + expect(result).toContain('リトライアシスタント'); + }); + it('loads score_slug_system_prompt with explicit lang', () => { const result = loadTemplate('score_slug_system_prompt', 'en'); expect(result).toContain('You are a slug generator'); diff --git a/src/__tests__/retryMode.test.ts b/src/__tests__/retryMode.test.ts new file mode 100644 index 0000000..4ef3dbe --- /dev/null +++ b/src/__tests__/retryMode.test.ts @@ -0,0 +1,147 @@ +/** + * Unit tests for retryMode: buildRetryTemplateVars + */ + +import { describe, it, expect } from 'vitest'; +import { buildRetryTemplateVars, type RetryContext } from '../features/interactive/retryMode.js'; + +function createRetryContext(overrides?: Partial): RetryContext { + return { + failure: { + taskName: 'my-task', + createdAt: '2026-02-15T10:00:00Z', + failedMovement: 'review', + error: 'Timeout', + lastMessage: 'Agent stopped', + retryNote: '', + }, + branchName: 'takt/my-task', + pieceContext: { + name: 'default', + description: '', + pieceStructure: '1. plan → 2. implement → 3. review', + movementPreviews: [], + }, + run: null, + ...overrides, + }; +} + +describe('buildRetryTemplateVars', () => { + it('should map failure info to template variables', () => { + const ctx = createRetryContext(); + const vars = buildRetryTemplateVars(ctx, 'en'); + + expect(vars.taskName).toBe('my-task'); + expect(vars.branchName).toBe('takt/my-task'); + expect(vars.createdAt).toBe('2026-02-15T10:00:00Z'); + expect(vars.failedMovement).toBe('review'); + expect(vars.failureError).toBe('Timeout'); + expect(vars.failureLastMessage).toBe('Agent stopped'); + }); + + it('should set empty string for absent optional fields', () => { + const ctx = createRetryContext({ + failure: { + taskName: 'task', + createdAt: '2026-01-01T00:00:00Z', + failedMovement: '', + error: 'Error', + lastMessage: '', + retryNote: '', + }, + }); + const vars = buildRetryTemplateVars(ctx, 'en'); + + expect(vars.failedMovement).toBe(''); + expect(vars.failureLastMessage).toBe(''); + expect(vars.retryNote).toBe(''); + }); + + it('should set hasRun=false and empty run vars when run is null', () => { + const ctx = createRetryContext({ run: null }); + const vars = buildRetryTemplateVars(ctx, 'en'); + + expect(vars.hasRun).toBe(false); + expect(vars.runLogsDir).toBe(''); + expect(vars.runReportsDir).toBe(''); + expect(vars.runTask).toBe(''); + expect(vars.runPiece).toBe(''); + expect(vars.runStatus).toBe(''); + expect(vars.runMovementLogs).toBe(''); + expect(vars.runReports).toBe(''); + }); + + it('should set hasRun=true and populate run vars when run is provided', () => { + const ctx = createRetryContext({ + run: { + logsDir: '/project/.takt/runs/slug/logs', + reportsDir: '/project/.takt/runs/slug/reports', + task: 'Build feature', + piece: 'default', + status: 'failed', + movementLogs: '### plan\nPlanned.', + reports: '### 00-plan.md\n# Plan', + }, + }); + const vars = buildRetryTemplateVars(ctx, 'en'); + + expect(vars.hasRun).toBe(true); + expect(vars.runLogsDir).toBe('/project/.takt/runs/slug/logs'); + expect(vars.runReportsDir).toBe('/project/.takt/runs/slug/reports'); + expect(vars.runTask).toBe('Build feature'); + expect(vars.runPiece).toBe('default'); + expect(vars.runStatus).toBe('failed'); + expect(vars.runMovementLogs).toBe('### plan\nPlanned.'); + expect(vars.runReports).toBe('### 00-plan.md\n# Plan'); + }); + + it('should set hasPiecePreview=false when no movement previews', () => { + const ctx = createRetryContext(); + const vars = buildRetryTemplateVars(ctx, 'en'); + + expect(vars.hasPiecePreview).toBe(false); + expect(vars.movementDetails).toBe(''); + }); + + it('should set hasPiecePreview=true and format movement details when previews exist', () => { + const ctx = createRetryContext({ + pieceContext: { + name: 'default', + description: '', + pieceStructure: '1. plan', + movementPreviews: [ + { + name: 'plan', + personaDisplayName: 'Architect', + personaContent: 'You are an architect.', + instructionContent: 'Plan the feature.', + allowedTools: ['Read', 'Grep'], + canEdit: false, + }, + ], + }, + }); + const vars = buildRetryTemplateVars(ctx, 'en'); + + expect(vars.hasPiecePreview).toBe(true); + expect(vars.movementDetails).toContain('plan'); + expect(vars.movementDetails).toContain('Architect'); + }); + + it('should include retryNote when present', () => { + const ctx = createRetryContext({ + failure: { + taskName: 'task', + createdAt: '2026-01-01T00:00:00Z', + failedMovement: '', + error: 'Error', + lastMessage: '', + retryNote: 'Added more specific error handling', + }, + }); + const vars = buildRetryTemplateVars(ctx, 'en'); + + expect(vars.retryNote).toBe('Added more specific error handling'); + }); +}); diff --git a/src/__tests__/runSessionReader.test.ts b/src/__tests__/runSessionReader.test.ts index d878db4..b8aedc5 100644 --- a/src/__tests__/runSessionReader.test.ts +++ b/src/__tests__/runSessionReader.test.ts @@ -14,6 +14,7 @@ vi.mock('../infra/fs/session.js', () => ({ import { loadNdjsonLog } from '../infra/fs/session.js'; import { listRecentRuns, + findRunForTask, loadRunSessionContext, formatRunSessionForPrompt, type RunSessionContext, @@ -107,6 +108,78 @@ describe('listRecentRuns', () => { }); }); +describe('findRunForTask', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = createTmpDir(); + vi.clearAllMocks(); + }); + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('should return null when no runs exist', () => { + const result = findRunForTask(tmpDir, 'Some task'); + expect(result).toBeNull(); + }); + + it('should return null when no runs match the task content', () => { + createRunDir(tmpDir, 'run-other', { + task: 'Different task', + piece: 'default', + status: 'completed', + startTime: '2026-02-01T00:00:00.000Z', + logsDirectory: '.takt/runs/run-other/logs', + reportDirectory: '.takt/runs/run-other/reports', + runSlug: 'run-other', + }); + + const result = findRunForTask(tmpDir, 'My specific task'); + expect(result).toBeNull(); + }); + + it('should return the matching run slug', () => { + createRunDir(tmpDir, 'run-match', { + task: 'Build login page', + piece: 'default', + status: 'failed', + startTime: '2026-02-01T00:00:00.000Z', + logsDirectory: '.takt/runs/run-match/logs', + reportDirectory: '.takt/runs/run-match/reports', + runSlug: 'run-match', + }); + + const result = findRunForTask(tmpDir, 'Build login page'); + expect(result).toBe('run-match'); + }); + + it('should return the most recent matching run when multiple exist', () => { + createRunDir(tmpDir, 'run-old', { + task: 'Build login page', + piece: 'default', + status: 'failed', + startTime: '2026-01-01T00:00:00.000Z', + logsDirectory: '.takt/runs/run-old/logs', + reportDirectory: '.takt/runs/run-old/reports', + runSlug: 'run-old', + }); + createRunDir(tmpDir, 'run-new', { + task: 'Build login page', + piece: 'default', + status: 'failed', + startTime: '2026-02-01T00:00:00.000Z', + logsDirectory: '.takt/runs/run-new/logs', + reportDirectory: '.takt/runs/run-new/reports', + runSlug: 'run-new', + }); + + const result = findRunForTask(tmpDir, 'Build login page'); + expect(result).toBe('run-new'); + }); +}); + describe('loadRunSessionContext', () => { let tmpDir: string; diff --git a/src/__tests__/taskInstructionActions.test.ts b/src/__tests__/taskInstructionActions.test.ts index 9e87db3..83d12e6 100644 --- a/src/__tests__/taskInstructionActions.test.ts +++ b/src/__tests__/taskInstructionActions.test.ts @@ -1,7 +1,10 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; const { + mockExistsSync, + mockStartReExecution, mockRequeueTask, + mockExecuteAndCompleteTask, mockRunInstructMode, mockDispatchConversationAction, mockSelectPiece, @@ -12,7 +15,10 @@ const { mockSelectRun, mockLoadRunSessionContext, } = vi.hoisted(() => ({ + mockExistsSync: vi.fn(() => true), + mockStartReExecution: vi.fn(), mockRequeueTask: vi.fn(), + mockExecuteAndCompleteTask: vi.fn(), mockRunInstructMode: vi.fn(), mockDispatchConversationAction: vi.fn(), mockSelectPiece: vi.fn(), @@ -24,9 +30,17 @@ const { mockLoadRunSessionContext: vi.fn(), })); +vi.mock('node:fs', async (importOriginal) => ({ + ...(await importOriginal>()), + existsSync: (...args: unknown[]) => mockExistsSync(...args), +})); + vi.mock('../infra/task/index.js', () => ({ detectDefaultBranch: vi.fn(() => 'main'), TaskRunner: class { + startReExecution(...args: unknown[]) { + return mockStartReExecution(...args); + } requeueTask(...args: unknown[]) { return mockRequeueTask(...args); } @@ -47,10 +61,6 @@ vi.mock('../features/tasks/list/instructMode.js', () => ({ runInstructMode: (...args: unknown[]) => mockRunInstructMode(...args), })); -vi.mock('../features/tasks/add/index.js', () => ({ - saveTaskFile: vi.fn(), -})); - vi.mock('../features/pieceSelection/index.js', () => ({ selectPiece: (...args: unknown[]) => mockSelectPiece(...args), })); @@ -74,9 +84,12 @@ vi.mock('../features/interactive/index.js', () => ({ loadRunSessionContext: (...args: unknown[]) => mockLoadRunSessionContext(...args), })); +vi.mock('../features/tasks/execute/taskExecution.js', () => ({ + executeAndCompleteTask: (...args: unknown[]) => mockExecuteAndCompleteTask(...args), +})); + vi.mock('../shared/ui/index.js', () => ({ info: vi.fn(), - success: vi.fn(), error: vi.fn(), })); @@ -90,10 +103,15 @@ vi.mock('../shared/utils/index.js', async (importOriginal) => ({ })); import { instructBranch } from '../features/tasks/list/taskActions.js'; +import { error as logError } from '../shared/ui/index.js'; -describe('instructBranch requeue flow', () => { +const mockLogError = vi.mocked(logError); + +describe('instructBranch direct execution flow', () => { beforeEach(() => { vi.clearAllMocks(); + mockExistsSync.mockReturnValue(true); + mockSelectPiece.mockResolvedValue('default'); mockRunInstructMode.mockResolvedValue({ action: 'execute', task: '追加指示A' }); mockDispatchConversationAction.mockImplementation(async (_result, handlers) => handlers.execute({ task: '追加指示A' })); @@ -102,9 +120,15 @@ describe('instructBranch requeue flow', () => { mockResolveLanguage.mockReturnValue('en'); mockListRecentRuns.mockReturnValue([]); mockSelectRun.mockResolvedValue(null); + mockStartReExecution.mockReturnValue({ + name: 'done-task', + content: 'done', + data: { task: 'done' }, + }); + mockExecuteAndCompleteTask.mockResolvedValue(true); }); - it('should requeue the same completed task instead of creating another task', async () => { + it('should execute directly via startReExecution instead of requeuing', async () => { const result = await instructBranch('/project', { kind: 'completed', name: 'done-task', @@ -117,12 +141,13 @@ describe('instructBranch requeue flow', () => { }); expect(result).toBe(true); - expect(mockRequeueTask).toHaveBeenCalledWith( + expect(mockStartReExecution).toHaveBeenCalledWith( 'done-task', ['completed', 'failed'], undefined, '既存ノート\n\n追加指示A', ); + expect(mockExecuteAndCompleteTask).toHaveBeenCalled(); }); it('should set generated instruction as retry note when no existing note', async () => { @@ -137,7 +162,7 @@ describe('instructBranch requeue flow', () => { data: { task: 'done' }, }); - expect(mockRequeueTask).toHaveBeenCalledWith( + expect(mockStartReExecution).toHaveBeenCalledWith( 'done-task', ['completed', 'failed'], undefined, @@ -145,7 +170,31 @@ describe('instructBranch requeue flow', () => { ); }); - it('should load selected run context and pass it to instruct mode', async () => { + it('should run instruct mode in existing worktree', async () => { + await instructBranch('/project', { + kind: 'completed', + name: 'done-task', + createdAt: '2026-02-14T00:00:00.000Z', + filePath: '/project/.takt/tasks.yaml', + content: 'done', + branch: 'takt/done-task', + worktreePath: '/project/.takt/worktrees/done-task', + data: { task: 'done' }, + }); + + expect(mockRunInstructMode).toHaveBeenCalledWith( + '/project/.takt/worktrees/done-task', + expect.any(String), + 'takt/done-task', + 'done-task', + 'done', + '', + expect.anything(), + undefined, + ); + }); + + it('should search runs in worktree for run session context', async () => { mockListRecentRuns.mockReturnValue([ { slug: 'run-1', task: 'fix', piece: 'default', status: 'completed', startTime: '2026-02-18T00:00:00Z' }, ]); @@ -165,14 +214,76 @@ describe('instructBranch requeue flow', () => { }); expect(mockConfirm).toHaveBeenCalledWith("Reference a previous run's results?", false); - expect(mockSelectRun).toHaveBeenCalledWith('/project', 'en'); - expect(mockLoadRunSessionContext).toHaveBeenCalledWith('/project', 'run-1'); + // selectRunSessionContext uses worktreePath for run data + expect(mockListRecentRuns).toHaveBeenCalledWith('/project/.takt/worktrees/done-task'); + expect(mockSelectRun).toHaveBeenCalledWith('/project/.takt/worktrees/done-task', 'en'); + expect(mockLoadRunSessionContext).toHaveBeenCalledWith('/project/.takt/worktrees/done-task', 'run-1'); expect(mockRunInstructMode).toHaveBeenCalledWith( - '/project', + '/project/.takt/worktrees/done-task', expect.any(String), 'takt/done-task', + 'done-task', + 'done', + '', expect.anything(), runContext, ); }); + + it('should return false when worktree does not exist', async () => { + mockExistsSync.mockReturnValue(false); + + const result = await instructBranch('/project', { + kind: 'completed', + name: 'done-task', + createdAt: '2026-02-14T00:00:00.000Z', + filePath: '/project/.takt/tasks.yaml', + content: 'done', + branch: 'takt/done-task', + worktreePath: '/project/.takt/worktrees/done-task', + data: { task: 'done' }, + }); + + expect(result).toBe(false); + expect(mockLogError).toHaveBeenCalledWith('Worktree directory does not exist for task: done-task'); + expect(mockStartReExecution).not.toHaveBeenCalled(); + }); + + it('should requeue task via requeueTask when save_task action', async () => { + mockDispatchConversationAction.mockImplementation(async (_result, handlers) => handlers.save_task({ task: '追加指示A' })); + + const result = await instructBranch('/project', { + kind: 'completed', + name: 'done-task', + createdAt: '2026-02-14T00:00:00.000Z', + filePath: '/project/.takt/tasks.yaml', + content: 'done', + branch: 'takt/done-task', + worktreePath: '/project/.takt/worktrees/done-task', + data: { task: 'done' }, + }); + + expect(result).toBe(true); + expect(mockRequeueTask).toHaveBeenCalledWith('done-task', ['completed', 'failed'], undefined, '追加指示A'); + expect(mockStartReExecution).not.toHaveBeenCalled(); + expect(mockExecuteAndCompleteTask).not.toHaveBeenCalled(); + }); + + it('should requeue task with existing retry note appended when save_task', async () => { + mockDispatchConversationAction.mockImplementation(async (_result, handlers) => handlers.save_task({ task: '追加指示A' })); + + const result = await instructBranch('/project', { + kind: 'completed', + name: 'done-task', + createdAt: '2026-02-14T00:00:00.000Z', + filePath: '/project/.takt/tasks.yaml', + content: 'done', + branch: 'takt/done-task', + worktreePath: '/project/.takt/worktrees/done-task', + data: { task: 'done', retry_note: '既存ノート' }, + }); + + expect(result).toBe(true); + expect(mockRequeueTask).toHaveBeenCalledWith('done-task', ['completed', 'failed'], undefined, '既存ノート\n\n追加指示A'); + }); }); diff --git a/src/__tests__/taskRetryActions.test.ts b/src/__tests__/taskRetryActions.test.ts index 10d6c23..b65f54d 100644 --- a/src/__tests__/taskRetryActions.test.ts +++ b/src/__tests__/taskRetryActions.test.ts @@ -1,17 +1,50 @@ -import * as fs from 'node:fs'; -import * as path from 'node:path'; -import * as os from 'node:os'; -import { stringify as stringifyYaml } from 'yaml'; -import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +const { + mockExistsSync, + mockSelectPiece, + mockSelectOption, + mockLoadGlobalConfig, + mockLoadPieceByIdentifier, + mockGetPieceDescription, + mockRunRetryMode, + mockFindRunForTask, + mockStartReExecution, + mockRequeueTask, + mockExecuteAndCompleteTask, +} = vi.hoisted(() => ({ + mockExistsSync: vi.fn(() => true), + mockSelectPiece: vi.fn(), + mockSelectOption: vi.fn(), + mockLoadGlobalConfig: vi.fn(), + mockLoadPieceByIdentifier: vi.fn(), + mockGetPieceDescription: vi.fn(() => ({ + name: 'default', + description: 'desc', + pieceStructure: '', + movementPreviews: [], + })), + mockRunRetryMode: vi.fn(), + mockFindRunForTask: vi.fn(() => null), + mockStartReExecution: vi.fn(), + mockRequeueTask: vi.fn(), + mockExecuteAndCompleteTask: vi.fn(), +})); + +vi.mock('node:fs', async (importOriginal) => ({ + ...(await importOriginal>()), + existsSync: (...args: unknown[]) => mockExistsSync(...args), +})); + +vi.mock('../features/pieceSelection/index.js', () => ({ + selectPiece: (...args: unknown[]) => mockSelectPiece(...args), +})); vi.mock('../shared/prompt/index.js', () => ({ - selectOption: vi.fn(), - confirm: vi.fn(), + selectOption: (...args: unknown[]) => mockSelectOption(...args), })); vi.mock('../shared/ui/index.js', () => ({ - success: vi.fn(), - error: vi.fn(), info: vi.fn(), header: vi.fn(), blankLine: vi.fn(), @@ -27,48 +60,39 @@ vi.mock('../shared/utils/index.js', async (importOriginal) => ({ })); vi.mock('../infra/config/index.js', () => ({ - loadGlobalConfig: vi.fn(), - loadPieceByIdentifier: vi.fn(), - getPieceDescription: vi.fn(() => ({ - name: 'default', - description: 'desc', - pieceStructure: '', - movementPreviews: [], - })), -})); - -vi.mock('../features/tasks/list/instructMode.js', () => ({ - runInstructMode: vi.fn(), + loadGlobalConfig: (...args: unknown[]) => mockLoadGlobalConfig(...args), + loadPieceByIdentifier: (...args: unknown[]) => mockLoadPieceByIdentifier(...args), + getPieceDescription: (...args: unknown[]) => mockGetPieceDescription(...args), })); vi.mock('../features/interactive/index.js', () => ({ - resolveLanguage: vi.fn(() => 'en'), - listRecentRuns: vi.fn(() => []), - selectRun: vi.fn(() => null), + findRunForTask: (...args: unknown[]) => mockFindRunForTask(...args), loadRunSessionContext: vi.fn(), + getRunPaths: vi.fn(() => ({ logsDir: '/tmp/logs', reportsDir: '/tmp/reports' })), + formatRunSessionForPrompt: vi.fn(() => ({ + runTask: '', runPiece: '', runStatus: '', runMovementLogs: '', runReports: '', + })), + runRetryMode: (...args: unknown[]) => mockRunRetryMode(...args), })); -vi.mock('../shared/i18n/index.js', () => ({ - getLabel: vi.fn(() => "Reference a previous run's results?"), +vi.mock('../infra/task/index.js', () => ({ + TaskRunner: class { + startReExecution(...args: unknown[]) { + return mockStartReExecution(...args); + } + requeueTask(...args: unknown[]) { + return mockRequeueTask(...args); + } + }, +})); + +vi.mock('../features/tasks/execute/taskExecution.js', () => ({ + executeAndCompleteTask: (...args: unknown[]) => mockExecuteAndCompleteTask(...args), })); -import { selectOption, confirm } from '../shared/prompt/index.js'; -import { success, error as logError } from '../shared/ui/index.js'; -import { loadGlobalConfig, loadPieceByIdentifier } from '../infra/config/index.js'; import { retryFailedTask } from '../features/tasks/list/taskRetryActions.js'; import type { TaskListItem } from '../infra/task/types.js'; import type { PieceConfig } from '../core/models/index.js'; -import { runInstructMode } from '../features/tasks/list/instructMode.js'; - -const mockSelectOption = vi.mocked(selectOption); -const mockConfirm = vi.mocked(confirm); -const mockSuccess = vi.mocked(success); -const mockLogError = vi.mocked(logError); -const mockLoadGlobalConfig = vi.mocked(loadGlobalConfig); -const mockLoadPieceByIdentifier = vi.mocked(loadPieceByIdentifier); -const mockRunInstructMode = vi.mocked(runInstructMode); - -let tmpDir: string; const defaultPieceConfig: PieceConfig = { name: 'default', @@ -82,115 +106,142 @@ const defaultPieceConfig: PieceConfig = { ], }; -function writeFailedTask(projectDir: string, name: string): TaskListItem { - const tasksFile = path.join(projectDir, '.takt', 'tasks.yaml'); - fs.mkdirSync(path.dirname(tasksFile), { recursive: true }); - fs.writeFileSync(tasksFile, stringifyYaml({ - tasks: [ - { - name, - status: 'failed', - content: 'Do something', - created_at: '2025-01-15T12:00:00.000Z', - started_at: '2025-01-15T12:01:00.000Z', - completed_at: '2025-01-15T12:02:00.000Z', - piece: 'default', - failure: { - movement: 'review', - error: 'Boom', - }, - }, - ], - }), 'utf-8'); - +function makeFailedTask(overrides?: Partial): TaskListItem { return { kind: 'failed', - name, + name: 'my-task', createdAt: '2025-01-15T12:02:00.000Z', - filePath: tasksFile, + filePath: '/project/.takt/tasks.yaml', content: 'Do something', + branch: 'takt/my-task', + worktreePath: '/project/.takt/worktrees/my-task', data: { task: 'Do something', piece: 'default' }, failure: { movement: 'review', error: 'Boom' }, + ...overrides, }; } beforeEach(() => { vi.clearAllMocks(); - tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'takt-test-retry-')); -}); + mockExistsSync.mockReturnValue(true); -afterEach(() => { - fs.rmSync(tmpDir, { recursive: true, force: true }); + mockSelectPiece.mockResolvedValue('default'); + mockLoadGlobalConfig.mockReturnValue({ defaultPiece: 'default' }); + mockLoadPieceByIdentifier.mockReturnValue(defaultPieceConfig); + mockSelectOption.mockResolvedValue('plan'); + mockRunRetryMode.mockResolvedValue({ action: 'execute', task: '追加指示A' }); + mockStartReExecution.mockReturnValue({ + name: 'my-task', + content: 'Do something', + data: { task: 'Do something', piece: 'default' }, + }); + mockExecuteAndCompleteTask.mockResolvedValue(true); }); describe('retryFailedTask', () => { - it('should requeue task with selected movement', async () => { - const task = writeFailedTask(tmpDir, 'my-task'); + it('should run retry mode in existing worktree and execute directly', async () => { + const task = makeFailedTask(); - mockLoadGlobalConfig.mockReturnValue({ defaultPiece: 'default' }); - mockLoadPieceByIdentifier.mockReturnValue(defaultPieceConfig); + const result = await retryFailedTask(task, '/project'); + + expect(result).toBe(true); + expect(mockSelectPiece).toHaveBeenCalledWith('/project'); + expect(mockRunRetryMode).toHaveBeenCalledWith( + '/project/.takt/worktrees/my-task', + expect.objectContaining({ + failure: expect.objectContaining({ taskName: 'my-task', taskContent: 'Do something' }), + }), + ); + expect(mockStartReExecution).toHaveBeenCalledWith('my-task', ['failed'], undefined, '追加指示A'); + expect(mockExecuteAndCompleteTask).toHaveBeenCalled(); + }); + + it('should pass non-initial movement as startMovement', async () => { + const task = makeFailedTask(); mockSelectOption.mockResolvedValue('implement'); - mockConfirm.mockResolvedValue(false); - mockRunInstructMode.mockResolvedValue({ action: 'execute', task: '追加指示A' }); - const result = await retryFailedTask(task, tmpDir); + await retryFailedTask(task, '/project'); - expect(result).toBe(true); - expect(mockSuccess).toHaveBeenCalledWith('Task requeued: my-task'); - - const tasksYaml = fs.readFileSync(path.join(tmpDir, '.takt', 'tasks.yaml'), 'utf-8'); - expect(tasksYaml).toContain('status: pending'); - expect(tasksYaml).toContain('start_movement: implement'); - expect(tasksYaml).toContain('retry_note: 追加指示A'); + expect(mockStartReExecution).toHaveBeenCalledWith('my-task', ['failed'], 'implement', '追加指示A'); }); - it('should not add start_movement when initial movement is selected', async () => { - const task = writeFailedTask(tmpDir, 'my-task'); + it('should not pass startMovement when initial movement is selected', async () => { + const task = makeFailedTask(); - mockLoadGlobalConfig.mockReturnValue({ defaultPiece: 'default' }); - mockLoadPieceByIdentifier.mockReturnValue(defaultPieceConfig); - mockSelectOption.mockResolvedValue('plan'); - mockConfirm.mockResolvedValue(false); - mockRunInstructMode.mockResolvedValue({ action: 'execute', task: '追加指示A' }); + await retryFailedTask(task, '/project'); - const result = await retryFailedTask(task, tmpDir); - - expect(result).toBe(true); - const tasksYaml = fs.readFileSync(path.join(tmpDir, '.takt', 'tasks.yaml'), 'utf-8'); - expect(tasksYaml).not.toContain('start_movement'); - expect(tasksYaml).toContain('retry_note: 追加指示A'); + expect(mockStartReExecution).toHaveBeenCalledWith('my-task', ['failed'], undefined, '追加指示A'); }); - it('should append generated instruction to existing retry note', async () => { - const task = writeFailedTask(tmpDir, 'my-task'); - task.data = { task: 'Do something', piece: 'default', retry_note: '既存ノート' }; + it('should append instruction to existing retry note', async () => { + const task = makeFailedTask({ data: { task: 'Do something', piece: 'default', retry_note: '既存ノート' } }); - mockLoadGlobalConfig.mockReturnValue({ defaultPiece: 'default' }); - mockLoadPieceByIdentifier.mockReturnValue(defaultPieceConfig); - mockSelectOption.mockResolvedValue('plan'); - mockConfirm.mockResolvedValue(false); - mockRunInstructMode.mockResolvedValue({ action: 'execute', task: '追加指示B' }); + await retryFailedTask(task, '/project'); - const result = await retryFailedTask(task, tmpDir); - - expect(result).toBe(true); - const tasksYaml = fs.readFileSync(path.join(tmpDir, '.takt', 'tasks.yaml'), 'utf-8'); - expect(tasksYaml).toContain('retry_note: |'); - expect(tasksYaml).toContain('既存ノート'); - expect(tasksYaml).toContain('追加指示B'); - }); - - it('should return false and show error when piece not found', async () => { - const task = writeFailedTask(tmpDir, 'my-task'); - - mockLoadGlobalConfig.mockReturnValue({ defaultPiece: 'default' }); - mockLoadPieceByIdentifier.mockReturnValue(null); - - const result = await retryFailedTask(task, tmpDir); - - expect(result).toBe(false); - expect(mockLogError).toHaveBeenCalledWith( - 'Piece "default" not found. Cannot determine available movements.', + expect(mockStartReExecution).toHaveBeenCalledWith( + 'my-task', ['failed'], undefined, '既存ノート\n\n追加指示A', ); }); + + it('should search runs in worktree, not projectDir', async () => { + const task = makeFailedTask(); + + await retryFailedTask(task, '/project'); + + expect(mockFindRunForTask).toHaveBeenCalledWith('/project/.takt/worktrees/my-task', 'Do something'); + }); + + it('should throw when worktree path is not set', async () => { + const task = makeFailedTask({ worktreePath: undefined }); + + await expect(retryFailedTask(task, '/project')).rejects.toThrow('Worktree path is not set'); + }); + + it('should throw when worktree directory does not exist', async () => { + mockExistsSync.mockReturnValue(false); + const task = makeFailedTask(); + + await expect(retryFailedTask(task, '/project')).rejects.toThrow('Worktree directory does not exist'); + }); + + it('should return false when piece selection is cancelled', async () => { + const task = makeFailedTask(); + mockSelectPiece.mockResolvedValue(null); + + const result = await retryFailedTask(task, '/project'); + + expect(result).toBe(false); + expect(mockLoadPieceByIdentifier).not.toHaveBeenCalled(); + }); + + it('should return false when retry mode is cancelled', async () => { + const task = makeFailedTask(); + mockRunRetryMode.mockResolvedValue({ action: 'cancel', task: '' }); + + const result = await retryFailedTask(task, '/project'); + + expect(result).toBe(false); + expect(mockStartReExecution).not.toHaveBeenCalled(); + }); + + it('should requeue task via requeueTask when save_task action', async () => { + const task = makeFailedTask(); + mockRunRetryMode.mockResolvedValue({ action: 'save_task', task: '追加指示A' }); + + const result = await retryFailedTask(task, '/project'); + + expect(result).toBe(true); + expect(mockRequeueTask).toHaveBeenCalledWith('my-task', ['failed'], undefined, '追加指示A'); + expect(mockStartReExecution).not.toHaveBeenCalled(); + expect(mockExecuteAndCompleteTask).not.toHaveBeenCalled(); + }); + + it('should requeue task with existing retry note appended when save_task', async () => { + const task = makeFailedTask({ data: { task: 'Do something', piece: 'default', retry_note: '既存ノート' } }); + mockRunRetryMode.mockResolvedValue({ action: 'save_task', task: '追加指示A' }); + + await retryFailedTask(task, '/project'); + + expect(mockRequeueTask).toHaveBeenCalledWith('my-task', ['failed'], undefined, '既存ノート\n\n追加指示A'); + }); }); diff --git a/src/app/cli/program.ts b/src/app/cli/program.ts index 314c8c2..728f8ca 100644 --- a/src/app/cli/program.ts +++ b/src/app/cli/program.ts @@ -12,7 +12,6 @@ import { initGlobalDirs, initProjectDirs, loadGlobalConfig, - getEffectiveDebugConfig, isVerboseMode, } from '../../infra/config/index.js'; import { setQuietMode } from '../../shared/context.js'; @@ -68,13 +67,7 @@ export async function runPreActionHook(): Promise { initProjectDirs(resolvedCwd); const verbose = isVerboseMode(resolvedCwd); - let debugConfig = getEffectiveDebugConfig(resolvedCwd); - - if (verbose && (!debugConfig || !debugConfig.enabled)) { - debugConfig = { enabled: true }; - } - - initDebugLogger(debugConfig, resolvedCwd); + initDebugLogger(verbose ? { enabled: true } : undefined, resolvedCwd); const config = loadGlobalConfig(); diff --git a/src/core/models/global-config.ts b/src/core/models/global-config.ts index f190e65..9214dfe 100644 --- a/src/core/models/global-config.ts +++ b/src/core/models/global-config.ts @@ -17,12 +17,6 @@ export interface CustomAgentConfig { model?: string; } -/** Debug configuration for takt */ -export interface DebugConfig { - enabled: boolean; - logFile?: string; -} - /** Observability configuration for runtime event logs */ export interface ObservabilityConfig { /** Enable provider stream event logging (default: false when undefined) */ @@ -63,7 +57,6 @@ export interface GlobalConfig { logLevel: 'debug' | 'info' | 'warn' | 'error'; provider?: 'claude' | 'codex' | 'opencode' | 'mock'; model?: string; - debug?: DebugConfig; observability?: ObservabilityConfig; /** Directory for shared clones (worktree_dir in config). If empty, uses ../{clone-name} relative to project */ worktreeDir?: string; diff --git a/src/core/models/index.ts b/src/core/models/index.ts index 6014899..8a07a10 100644 --- a/src/core/models/index.ts +++ b/src/core/models/index.ts @@ -27,7 +27,6 @@ export type { PieceConfig, PieceState, CustomAgentConfig, - DebugConfig, ObservabilityConfig, Language, PipelineConfig, diff --git a/src/core/models/schemas.ts b/src/core/models/schemas.ts index 36a7f73..c36ace7 100644 --- a/src/core/models/schemas.ts +++ b/src/core/models/schemas.ts @@ -374,12 +374,6 @@ export const CustomAgentConfigSchema = z.object({ { message: 'Agent must have prompt_file, prompt, claude_agent, or claude_skill' } ); -/** Debug config schema */ -export const DebugConfigSchema = z.object({ - enabled: z.boolean().optional().default(false), - log_file: z.string().optional(), -}); - export const ObservabilityConfigSchema = z.object({ provider_events: z.boolean().optional(), }); @@ -415,7 +409,6 @@ export const GlobalConfigSchema = z.object({ log_level: z.enum(['debug', 'info', 'warn', 'error']).optional().default('info'), provider: z.enum(['claude', 'codex', 'opencode', 'mock']).optional().default('claude'), model: z.string().optional(), - debug: DebugConfigSchema.optional(), observability: ObservabilityConfigSchema.optional(), /** Directory for shared clones (worktree_dir in config). If empty, uses ../{clone-name} relative to project */ worktree_dir: z.string().optional(), diff --git a/src/core/models/types.ts b/src/core/models/types.ts index e3f64d4..c68197d 100644 --- a/src/core/models/types.ts +++ b/src/core/models/types.ts @@ -62,7 +62,6 @@ export type { // Configuration types (global and project) export type { CustomAgentConfig, - DebugConfig, ObservabilityConfig, Language, PipelineConfig, diff --git a/src/features/interactive/index.ts b/src/features/interactive/index.ts index 85baa1e..36fb96b 100644 --- a/src/features/interactive/index.ts +++ b/src/features/interactive/index.ts @@ -22,5 +22,6 @@ export { passthroughMode } from './passthroughMode.js'; export { quietMode } from './quietMode.js'; export { personaMode } from './personaMode.js'; export { selectRun } from './runSelector.js'; -export { listRecentRuns, loadRunSessionContext, type RunSessionContext } from './runSessionReader.js'; +export { listRecentRuns, findRunForTask, loadRunSessionContext, formatRunSessionForPrompt, getRunPaths, type RunSessionContext, type RunPaths } from './runSessionReader.js'; +export { runRetryMode, buildRetryTemplateVars, type RetryContext, type RetryFailureInfo, type RetryRunInfo } from './retryMode.js'; export { dispatchConversationAction, type ConversationActionResult } from './actionDispatcher.js'; diff --git a/src/features/interactive/retryMode.ts b/src/features/interactive/retryMode.ts new file mode 100644 index 0000000..4b2bbbe --- /dev/null +++ b/src/features/interactive/retryMode.ts @@ -0,0 +1,167 @@ +/** + * Retry mode for failed tasks. + * + * Provides a dedicated conversation loop with failure context, + * run session data, and piece structure injected into the system prompt. + */ + +import { + initializeSession, + displayAndClearSessionState, + runConversationLoop, + type SessionContext, + type ConversationStrategy, + type PostSummaryAction, +} from './conversationLoop.js'; +import { + buildSummaryActionOptions, + selectSummaryAction, + formatMovementPreviews, + type PieceContext, +} from './interactive-summary.js'; +import { resolveLanguage } from './interactive.js'; +import { loadTemplate } from '../../shared/prompts/index.js'; +import { getLabelObject } from '../../shared/i18n/index.js'; +import { loadGlobalConfig } from '../../infra/config/index.js'; +import type { InstructModeResult, InstructUIText } from '../tasks/list/instructMode.js'; + +/** Failure information for a retry task */ +export interface RetryFailureInfo { + readonly taskName: string; + readonly taskContent: string; + readonly createdAt: string; + readonly failedMovement: string; + readonly error: string; + readonly lastMessage: string; + readonly retryNote: string; +} + +/** Run session reference data for retry prompt */ +export interface RetryRunInfo { + readonly logsDir: string; + readonly reportsDir: string; + readonly task: string; + readonly piece: string; + readonly status: string; + readonly movementLogs: string; + readonly reports: string; +} + +/** Full retry context assembled by the caller */ +export interface RetryContext { + readonly failure: RetryFailureInfo; + readonly branchName: string; + readonly pieceContext: PieceContext; + readonly run: RetryRunInfo | null; +} + +const RETRY_TOOLS = ['Read', 'Glob', 'Grep', 'Bash', 'WebSearch', 'WebFetch']; + +/** + * Convert RetryContext into template variable map. + */ +export function buildRetryTemplateVars(ctx: RetryContext, lang: 'en' | 'ja'): Record { + const hasPiecePreview = !!ctx.pieceContext.movementPreviews?.length; + const movementDetails = hasPiecePreview + ? formatMovementPreviews(ctx.pieceContext.movementPreviews!, lang) + : ''; + + const hasRun = ctx.run !== null; + + return { + taskName: ctx.failure.taskName, + taskContent: ctx.failure.taskContent, + branchName: ctx.branchName, + createdAt: ctx.failure.createdAt, + failedMovement: ctx.failure.failedMovement, + failureError: ctx.failure.error, + failureLastMessage: ctx.failure.lastMessage, + retryNote: ctx.failure.retryNote, + hasPiecePreview, + pieceStructure: ctx.pieceContext.pieceStructure, + movementDetails, + hasRun, + runLogsDir: hasRun ? ctx.run!.logsDir : '', + runReportsDir: hasRun ? ctx.run!.reportsDir : '', + runTask: hasRun ? ctx.run!.task : '', + runPiece: hasRun ? ctx.run!.piece : '', + runStatus: hasRun ? ctx.run!.status : '', + runMovementLogs: hasRun ? ctx.run!.movementLogs : '', + runReports: hasRun ? ctx.run!.reports : '', + }; +} + +function createSelectRetryAction(ui: InstructUIText): (task: string, lang: 'en' | 'ja') => Promise { + return async (task: string, _lang: 'en' | 'ja'): Promise => { + return selectSummaryAction( + task, + ui.proposed, + ui.actionPrompt, + buildSummaryActionOptions({ + execute: ui.actions.execute, + saveTask: ui.actions.saveTask, + continue: ui.actions.continue, + }), + ); + }; +} + +/** + * Run retry mode conversation loop. + * + * Uses a dedicated system prompt with failure context, run session data, + * and piece structure injected for the AI assistant. + */ +export async function runRetryMode( + cwd: string, + retryContext: RetryContext, +): Promise { + const globalConfig = loadGlobalConfig(); + const lang = resolveLanguage(globalConfig.language); + + if (!globalConfig.provider) { + throw new Error('Provider is not configured.'); + } + + const baseCtx = initializeSession(cwd, 'retry'); + const ctx: SessionContext = { ...baseCtx, lang, personaName: 'retry' }; + + displayAndClearSessionState(cwd, ctx.lang); + + const ui = getLabelObject('instruct.ui', ctx.lang); + + const templateVars = buildRetryTemplateVars(retryContext, lang); + const systemPrompt = loadTemplate('score_retry_system_prompt', ctx.lang, templateVars); + + const introLabel = ctx.lang === 'ja' + ? `## リトライ: ${retryContext.failure.taskName}\n\nブランチ: ${retryContext.branchName}\n\n${ui.intro}` + : `## Retry: ${retryContext.failure.taskName}\n\nBranch: ${retryContext.branchName}\n\n${ui.intro}`; + + const policyContent = loadTemplate('score_interactive_policy', ctx.lang, {}); + + function injectPolicy(userMessage: string): string { + const policyIntro = ctx.lang === 'ja' + ? '以下のポリシーは行動規範です。必ず遵守してください。' + : 'The following policy defines behavioral guidelines. Please follow them.'; + const reminderLabel = ctx.lang === 'ja' + ? '上記の Policy セクションで定義されたポリシー規範を遵守してください。' + : 'Please follow the policy guidelines defined in the Policy section above.'; + return `## Policy\n${policyIntro}\n\n${policyContent}\n\n---\n\n${userMessage}\n\n---\n**Policy Reminder:** ${reminderLabel}`; + } + + const strategy: ConversationStrategy = { + systemPrompt, + allowedTools: RETRY_TOOLS, + transformPrompt: injectPolicy, + introMessage: introLabel, + selectAction: createSelectRetryAction(ui), + }; + + const result = await runConversationLoop(cwd, ctx, strategy, retryContext.pieceContext, undefined); + + if (result.action === 'cancel') { + return { action: 'cancel', task: '' }; + } + + return { action: result.action as InstructModeResult['action'], task: result.task }; +} diff --git a/src/features/interactive/runSessionReader.ts b/src/features/interactive/runSessionReader.ts index 3e7a293..28e672d 100644 --- a/src/features/interactive/runSessionReader.ts +++ b/src/features/interactive/runSessionReader.ts @@ -48,6 +48,12 @@ export interface RunSessionContext { readonly reports: readonly ReportEntry[]; } +/** Absolute paths to a run's logs and reports directories */ +export interface RunPaths { + readonly logsDir: string; + readonly reportsDir: string; +} + interface MetaJson { readonly task: string; readonly piece: string; @@ -150,6 +156,33 @@ export function listRecentRuns(cwd: string): RunSummary[] { return summaries.slice(0, MAX_RUNS); } +/** + * Find the most recent run matching the given task content. + * + * @returns The run slug if found, null otherwise. + */ +export function findRunForTask(cwd: string, taskContent: string): string | null { + const runs = listRecentRuns(cwd); + const match = runs.find((r) => r.task === taskContent); + return match?.slug ?? null; +} + +/** + * Get absolute paths to a run's logs and reports directories. + */ +export function getRunPaths(cwd: string, slug: string): RunPaths { + const metaPath = join(cwd, '.takt', 'runs', slug, 'meta.json'); + const meta = parseMetaJson(metaPath); + if (!meta) { + throw new Error(`Run not found: ${slug}`); + } + + return { + logsDir: join(cwd, meta.logsDirectory), + reportsDir: join(cwd, meta.reportDirectory), + }; +} + /** * Load full run session context for prompt injection. */ diff --git a/src/features/tasks/execute/resolveTask.ts b/src/features/tasks/execute/resolveTask.ts index ebf2cc2..f4e5c3b 100644 --- a/src/features/tasks/execute/resolveTask.ts +++ b/src/features/tasks/execute/resolveTask.ts @@ -96,28 +96,37 @@ export async function resolveTaskExecution( if (data.worktree) { throwIfAborted(abortSignal); baseBranch = getCurrentBranch(defaultCwd); - const taskSlug = await withProgress( - 'Generating branch name...', - (slug) => `Branch name generated: ${slug}`, - () => summarizeTaskName(task.content, { cwd: defaultCwd }), - ); - throwIfAborted(abortSignal); - const result = await withProgress( - 'Creating clone...', - (cloneResult) => `Clone created: ${cloneResult.path} (branch: ${cloneResult.branch})`, - async () => createSharedClone(defaultCwd, { - worktree: data.worktree!, - branch: data.branch, - taskSlug, - issueNumber: data.issue, - }), - ); - throwIfAborted(abortSignal); - execCwd = result.path; - branch = result.branch; - worktreePath = result.path; - isWorktree = true; + if (task.worktreePath && fs.existsSync(task.worktreePath)) { + // Reuse existing worktree (clone still on disk from previous execution) + execCwd = task.worktreePath; + branch = data.branch; + worktreePath = task.worktreePath; + isWorktree = true; + } else { + const taskSlug = await withProgress( + 'Generating branch name...', + (slug) => `Branch name generated: ${slug}`, + () => summarizeTaskName(task.content, { cwd: defaultCwd }), + ); + + throwIfAborted(abortSignal); + const result = await withProgress( + 'Creating clone...', + (cloneResult) => `Clone created: ${cloneResult.path} (branch: ${cloneResult.branch})`, + async () => createSharedClone(defaultCwd, { + worktree: data.worktree!, + branch: data.branch, + taskSlug, + issueNumber: data.issue, + }), + ); + throwIfAborted(abortSignal); + execCwd = result.path; + branch = result.branch; + worktreePath = result.path; + isWorktree = true; + } } if (task.taskDir && reportDirName) { diff --git a/src/features/tasks/list/index.ts b/src/features/tasks/list/index.ts index 6016630..351cbe3 100644 --- a/src/features/tasks/list/index.ts +++ b/src/features/tasks/list/index.ts @@ -181,7 +181,7 @@ export async function listTasks( showFullDiff(cwd, task.branch); break; case 'instruct': - await instructBranch(cwd, task, options); + await instructBranch(cwd, task); break; case 'try': tryMergeBranch(cwd, task); diff --git a/src/features/tasks/list/instructMode.ts b/src/features/tasks/list/instructMode.ts index 992d037..6cc56b8 100644 --- a/src/features/tasks/list/instructMode.ts +++ b/src/features/tasks/list/instructMode.ts @@ -17,6 +17,7 @@ import { resolveLanguage, buildSummaryActionOptions, selectSummaryAction, + formatMovementPreviews, type PieceContext, } from '../../interactive/interactive.js'; import { type RunSessionContext, formatRunSessionForPrompt } from '../../interactive/runSessionReader.js'; @@ -64,10 +65,47 @@ function createSelectInstructAction(ui: InstructUIText): (task: string, lang: 'e }; } +function buildInstructTemplateVars( + branchContext: string, + branchName: string, + taskName: string, + taskContent: string, + retryNote: string, + lang: 'en' | 'ja', + pieceContext?: PieceContext, + runSessionContext?: RunSessionContext, +): Record { + const hasPiecePreview = !!pieceContext?.movementPreviews?.length; + const movementDetails = hasPiecePreview + ? formatMovementPreviews(pieceContext!.movementPreviews!, lang) + : ''; + + const hasRunSession = !!runSessionContext; + const runPromptVars = hasRunSession + ? formatRunSessionForPrompt(runSessionContext) + : { runTask: '', runPiece: '', runStatus: '', runMovementLogs: '', runReports: '' }; + + return { + taskName, + taskContent, + branchName, + branchContext, + retryNote, + hasPiecePreview, + pieceStructure: pieceContext?.pieceStructure ?? '', + movementDetails, + hasRunSession, + ...runPromptVars, + }; +} + export async function runInstructMode( cwd: string, branchContext: string, branchName: string, + taskName: string, + taskContent: string, + retryNote: string, pieceContext?: PieceContext, runSessionContext?: RunSessionContext, ): Promise { @@ -85,24 +123,11 @@ export async function runInstructMode( const ui = getLabelObject('instruct.ui', ctx.lang); - const hasRunSession = !!runSessionContext; - const runPromptVars = hasRunSession - ? formatRunSessionForPrompt(runSessionContext) - : { runTask: '', runPiece: '', runStatus: '', runMovementLogs: '', runReports: '' }; - - const systemPrompt = loadTemplate('score_interactive_system_prompt', ctx.lang, { - hasPiecePreview: false, - pieceStructure: '', - movementDetails: '', - hasRunSession, - ...runPromptVars, - }); - - const branchIntro = ctx.lang === 'ja' - ? `## ブランチ: ${branchName}\n\n${branchContext}` - : `## Branch: ${branchName}\n\n${branchContext}`; - - const introMessage = `${branchIntro}\n\n${ui.intro}`; + const templateVars = buildInstructTemplateVars( + branchContext, branchName, taskName, taskContent, retryNote, lang, + pieceContext, runSessionContext, + ); + const systemPrompt = loadTemplate('score_instruct_system_prompt', ctx.lang, templateVars); const policyContent = loadTemplate('score_interactive_policy', ctx.lang, {}); @@ -120,7 +145,7 @@ export async function runInstructMode( systemPrompt, allowedTools: INSTRUCT_TOOLS, transformPrompt: injectPolicy, - introMessage, + introMessage: ui.intro, selectAction: createSelectInstructAction(ui), }; diff --git a/src/features/tasks/list/taskInstructionActions.ts b/src/features/tasks/list/taskInstructionActions.ts index 9561698..578962a 100644 --- a/src/features/tasks/list/taskInstructionActions.ts +++ b/src/features/tasks/list/taskInstructionActions.ts @@ -1,19 +1,27 @@ +/** + * Instruction actions for completed/failed tasks. + * + * Uses the existing worktree (clone) for conversation and direct re-execution. + * The worktree is preserved after initial execution, so no clone creation is needed. + */ + +import * as fs from 'node:fs'; import { execFileSync } from 'node:child_process'; import { TaskRunner, + detectDefaultBranch, } from '../../../infra/task/index.js'; import { loadGlobalConfig, getPieceDescription } from '../../../infra/config/index.js'; -import { info, success } from '../../../shared/ui/index.js'; +import { info, error as logError } from '../../../shared/ui/index.js'; import { createLogger, getErrorMessage } from '../../../shared/utils/index.js'; -import type { TaskExecutionOptions } from '../execute/types.js'; import { runInstructMode } from './instructMode.js'; import { selectPiece } from '../../pieceSelection/index.js'; import { dispatchConversationAction } from '../../interactive/actionDispatcher.js'; import type { PieceContext } from '../../interactive/interactive.js'; import { resolveLanguage } from '../../interactive/index.js'; import { type BranchActionTarget, resolveTargetBranch } from './taskActionTarget.js'; -import { detectDefaultBranch } from '../../../infra/task/index.js'; import { appendRetryNote, selectRunSessionContext } from './requeueHelpers.js'; +import { executeAndCompleteTask } from '../execute/taskExecution.js'; const log = createLogger('list-tasks'); @@ -66,12 +74,17 @@ function getBranchContext(projectDir: string, branch: string): string { export async function instructBranch( projectDir: string, target: BranchActionTarget, - _options?: TaskExecutionOptions, ): Promise { if (!('kind' in target)) { throw new Error('Instruct requeue requires a task target.'); } + if (!target.worktreePath || !fs.existsSync(target.worktreePath)) { + logError(`Worktree directory does not exist for task: ${target.name}`); + return false; + } + const worktreePath = target.worktreePath; + const branch = resolveTargetBranch(target); const selectedPiece = await selectPiece(projectDir); @@ -90,23 +103,30 @@ export async function instructBranch( }; const lang = resolveLanguage(globalConfig.language); - const runSessionContext = await selectRunSessionContext(projectDir, lang); + // Runs data lives in the worktree (written during previous execution) + const runSessionContext = await selectRunSessionContext(worktreePath, lang); const branchContext = getBranchContext(projectDir, branch); - const result = await runInstructMode(projectDir, branchContext, branch, pieceContext, runSessionContext); - const requeueWithInstruction = async (instruction: string): Promise => { - const runner = new TaskRunner(projectDir); + const result = await runInstructMode( + worktreePath, branchContext, branch, + target.name, target.content, target.data?.retry_note ?? '', + pieceContext, runSessionContext, + ); + + const executeWithInstruction = async (instruction: string): Promise => { const retryNote = appendRetryNote(target.data?.retry_note, instruction); - runner.requeueTask(target.name, ['completed', 'failed'], undefined, retryNote); - success(`Task requeued with additional instructions: ${target.name}`); - info(` Branch: ${branch}`); - log.info('Requeued task from instruct mode', { + const runner = new TaskRunner(projectDir); + const taskInfo = runner.startReExecution(target.name, ['completed', 'failed'], undefined, retryNote); + + log.info('Starting re-execution of instructed task', { name: target.name, + worktreePath, branch, piece: selectedPiece, }); - return true; + + return executeAndCompleteTask(taskInfo, runner, projectDir, selectedPiece); }; return dispatchConversationAction(result, { @@ -114,7 +134,13 @@ export async function instructBranch( info('Cancelled'); return false; }, - execute: async ({ task }) => requeueWithInstruction(task), - save_task: async ({ task }) => requeueWithInstruction(task), + execute: async ({ task }) => executeWithInstruction(task), + save_task: async ({ task }) => { + const retryNote = appendRetryNote(target.data?.retry_note, task); + const runner = new TaskRunner(projectDir); + runner.requeueTask(target.name, ['completed', 'failed'], undefined, retryNote); + info(`Task "${target.name}" has been requeued.`); + return true; + }, }); } diff --git a/src/features/tasks/list/taskRetryActions.ts b/src/features/tasks/list/taskRetryActions.ts index 3c45dcd..4a4bfce 100644 --- a/src/features/tasks/list/taskRetryActions.ts +++ b/src/features/tasks/list/taskRetryActions.ts @@ -1,22 +1,31 @@ /** * Retry actions for failed tasks. * - * Provides interactive retry functionality including - * failure info display and movement selection. + * Uses the existing worktree (clone) for conversation and direct re-execution. + * The worktree is preserved after initial execution, so no clone creation is needed. */ +import * as fs from 'node:fs'; import type { TaskListItem } from '../../../infra/task/index.js'; import { TaskRunner } from '../../../infra/task/index.js'; import { loadPieceByIdentifier, loadGlobalConfig, getPieceDescription } from '../../../infra/config/index.js'; +import { selectPiece } from '../../pieceSelection/index.js'; import { selectOption } from '../../../shared/prompt/index.js'; -import { success, error as logError, info, header, blankLine, status } from '../../../shared/ui/index.js'; -import { createLogger, getErrorMessage } from '../../../shared/utils/index.js'; +import { info, header, blankLine, status } from '../../../shared/ui/index.js'; +import { createLogger } from '../../../shared/utils/index.js'; import type { PieceConfig } from '../../../core/models/index.js'; -import { runInstructMode } from './instructMode.js'; -import type { PieceContext } from '../../interactive/interactive.js'; -import { resolveLanguage, selectRun, loadRunSessionContext, listRecentRuns, type RunSessionContext } from '../../interactive/index.js'; -import { getLabel } from '../../../shared/i18n/index.js'; -import { confirm } from '../../../shared/prompt/index.js'; +import { + findRunForTask, + loadRunSessionContext, + getRunPaths, + formatRunSessionForPrompt, + runRetryMode, + type RetryContext, + type RetryFailureInfo, + type RetryRunInfo, +} from '../../interactive/index.js'; +import { executeAndCompleteTask } from '../execute/taskExecution.js'; +import { appendRetryNote } from './requeueHelpers.js'; const log = createLogger('list-tasks'); @@ -58,42 +67,53 @@ async function selectStartMovement( return await selectOption('Start from movement:', options); } -function appendRetryNote(existing: string | undefined, additional: string): string { - const trimmedAdditional = additional.trim(); - if (trimmedAdditional === '') { - throw new Error('Additional instruction is empty.'); - } - if (!existing || existing.trim() === '') { - return trimmedAdditional; - } - return `${existing}\n\n${trimmedAdditional}`; +function buildRetryFailureInfo(task: TaskListItem): RetryFailureInfo { + return { + taskName: task.name, + taskContent: task.content, + createdAt: task.createdAt, + failedMovement: task.failure?.movement ?? '', + error: task.failure?.error ?? '', + lastMessage: task.failure?.last_message ?? '', + retryNote: task.data?.retry_note ?? '', + }; } -function buildRetryBranchContext(task: TaskListItem): string { - const lines = [ - '## 失敗情報', - `- タスク名: ${task.name}`, - `- 失敗日時: ${task.createdAt}`, - ]; - if (task.failure?.movement) { - lines.push(`- 失敗ムーブメント: ${task.failure.movement}`); +function buildRetryRunInfo( + runsBaseDir: string, + slug: string, +): RetryRunInfo { + const paths = getRunPaths(runsBaseDir, slug); + const sessionContext = loadRunSessionContext(runsBaseDir, slug); + const formatted = formatRunSessionForPrompt(sessionContext); + return { + logsDir: paths.logsDir, + reportsDir: paths.reportsDir, + task: formatted.runTask, + piece: formatted.runPiece, + status: formatted.runStatus, + movementLogs: formatted.runMovementLogs, + reports: formatted.runReports, + }; +} + +function resolveWorktreePath(task: TaskListItem): string { + if (!task.worktreePath) { + throw new Error(`Worktree path is not set for task: ${task.name}`); } - if (task.failure?.error) { - lines.push(`- エラー: ${task.failure.error}`); + if (!fs.existsSync(task.worktreePath)) { + throw new Error(`Worktree directory does not exist: ${task.worktreePath}`); } - if (task.failure?.last_message) { - lines.push(`- 最終メッセージ: ${task.failure.last_message}`); - } - if (task.data?.retry_note) { - lines.push('', '## 既存の再投入メモ', task.data.retry_note); - } - return `${lines.join('\n')}\n`; + return task.worktreePath; } /** * Retry a failed task. * - * @returns true if task was requeued, false if cancelled + * Runs the retry conversation in the existing worktree, then directly + * re-executes the task there (auto-commit + push + status update). + * + * @returns true if task was re-executed successfully, false if cancelled or failed */ export async function retryFailedTask( task: TaskListItem, @@ -103,15 +123,21 @@ export async function retryFailedTask( throw new Error(`retryFailedTask requires failed task. received: ${task.kind}`); } + const worktreePath = resolveWorktreePath(task); + displayFailureInfo(task); + const selectedPiece = await selectPiece(projectDir); + if (!selectedPiece) { + info('Cancelled'); + return false; + } + const globalConfig = loadGlobalConfig(); - const pieceName = task.data?.piece ?? globalConfig.defaultPiece ?? 'default'; - const pieceConfig = loadPieceByIdentifier(pieceName, projectDir); + const pieceConfig = loadPieceByIdentifier(selectedPiece, projectDir); if (!pieceConfig) { - logError(`Piece "${pieceName}" not found. Cannot determine available movements.`); - return false; + throw new Error(`Piece "${selectedPiece}" not found after selection.`); } const selectedMovement = await selectStartMovement(pieceConfig, task.failure?.movement ?? null); @@ -119,72 +145,51 @@ export async function retryFailedTask( return false; } - const pieceDesc = getPieceDescription(pieceName, projectDir, globalConfig.interactivePreviewMovements); - const pieceContext: PieceContext = { + const pieceDesc = getPieceDescription(selectedPiece, projectDir, globalConfig.interactivePreviewMovements); + const pieceContext = { name: pieceDesc.name, description: pieceDesc.description, pieceStructure: pieceDesc.pieceStructure, movementPreviews: pieceDesc.movementPreviews, }; - const lang = resolveLanguage(globalConfig.language); - let runSessionContext: RunSessionContext | undefined; - const hasRuns = listRecentRuns(projectDir).length > 0; - if (hasRuns) { - const shouldReferenceRun = await confirm( - getLabel('interactive.runSelector.confirm', lang), - false, - ); - if (shouldReferenceRun) { - const selectedSlug = await selectRun(projectDir, lang); - if (selectedSlug) { - runSessionContext = loadRunSessionContext(projectDir, selectedSlug); - } - } - } + // Runs data lives in the worktree (written during previous execution) + const matchedSlug = findRunForTask(worktreePath, task.content); + const runInfo = matchedSlug ? buildRetryRunInfo(worktreePath, matchedSlug) : null; blankLine(); - const branchContext = buildRetryBranchContext(task); const branchName = task.branch ?? task.name; - const instructResult = await runInstructMode( - projectDir, - branchContext, + const retryContext: RetryContext = { + failure: buildRetryFailureInfo(task), branchName, pieceContext, - runSessionContext, - ); - if (instructResult.action !== 'execute') { + run: runInfo, + }; + + const retryResult = await runRetryMode(worktreePath, retryContext); + if (retryResult.action === 'cancel') { return false; } - try { - const runner = new TaskRunner(projectDir); - const startMovement = selectedMovement !== pieceConfig.initialMovement - ? selectedMovement - : undefined; - const retryNote = appendRetryNote(task.data?.retry_note, instructResult.task); + const startMovement = selectedMovement !== pieceConfig.initialMovement + ? selectedMovement + : undefined; + const retryNote = appendRetryNote(task.data?.retry_note, retryResult.task); + const runner = new TaskRunner(projectDir); + if (retryResult.action === 'save_task') { runner.requeueTask(task.name, ['failed'], startMovement, retryNote); - - success(`Task requeued: ${task.name}`); - if (startMovement) { - info(` Will start from: ${startMovement}`); - } - info(' Retry note: updated'); - info(` File: ${task.filePath}`); - - log.info('Requeued failed task', { - name: task.name, - tasksFile: task.filePath, - startMovement, - retryNote, - }); - + info(`Task "${task.name}" has been requeued.`); return true; - } catch (err) { - const msg = getErrorMessage(err); - logError(`Failed to requeue task: ${msg}`); - log.error('Failed to requeue task', { name: task.name, error: msg }); - return false; } + + const taskInfo = runner.startReExecution(task.name, ['failed'], startMovement, retryNote); + + log.info('Starting re-execution of failed task', { + name: task.name, + worktreePath, + startMovement, + }); + + return executeAndCompleteTask(taskInfo, runner, projectDir, selectedPiece); } diff --git a/src/infra/config/global/globalConfig.ts b/src/infra/config/global/globalConfig.ts index 13f4ba4..f233245 100644 --- a/src/infra/config/global/globalConfig.ts +++ b/src/infra/config/global/globalConfig.ts @@ -1,7 +1,7 @@ /** * Global configuration loader * - * Manages ~/.takt/config.yaml and project-level debug settings. + * Manages ~/.takt/config.yaml. * GlobalConfigManager encapsulates the config cache as a singleton. */ @@ -9,10 +9,10 @@ import { readFileSync, existsSync, writeFileSync, statSync, accessSync, constant import { isAbsolute } from 'node:path'; import { parse as parseYaml, stringify as stringifyYaml } from 'yaml'; import { GlobalConfigSchema } from '../../../core/models/index.js'; -import type { GlobalConfig, DebugConfig, Language } from '../../../core/models/index.js'; +import type { GlobalConfig, Language } from '../../../core/models/index.js'; import type { ProviderPermissionProfiles } from '../../../core/models/provider-profiles.js'; import { normalizeProviderOptions } from '../loaders/pieceParser.js'; -import { getGlobalConfigPath, getProjectConfigPath } from '../paths.js'; +import { getGlobalConfigPath } from '../paths.js'; import { DEFAULT_LANGUAGE } from '../../../shared/constants.js'; import { parseProviderModel } from '../../../shared/utils/providerModel.js'; @@ -168,10 +168,6 @@ export class GlobalConfigManager { logLevel: parsed.log_level, provider: parsed.provider, model: parsed.model, - debug: parsed.debug ? { - enabled: parsed.debug.enabled, - logFile: parsed.debug.log_file, - } : undefined, observability: parsed.observability ? { providerEvents: parsed.observability.provider_events, } : undefined, @@ -228,12 +224,6 @@ export class GlobalConfigManager { if (config.model) { raw.model = config.model; } - if (config.debug) { - raw.debug = { - enabled: config.debug.enabled, - log_file: config.debug.logFile, - }; - } if (config.observability && config.observability.providerEvents !== undefined) { raw.observability = { provider_events: config.observability.providerEvents, @@ -458,41 +448,3 @@ export function resolveOpencodeApiKey(): string | undefined { } } -/** Load project-level debug configuration (from .takt/config.yaml) */ -export function loadProjectDebugConfig(projectDir: string): DebugConfig | undefined { - const configPath = getProjectConfigPath(projectDir); - if (!existsSync(configPath)) { - return undefined; - } - try { - const content = readFileSync(configPath, 'utf-8'); - const raw = parseYaml(content); - if (raw && typeof raw === 'object' && 'debug' in raw) { - const debug = raw.debug; - if (debug && typeof debug === 'object') { - return { - enabled: Boolean(debug.enabled), - logFile: typeof debug.log_file === 'string' ? debug.log_file : undefined, - }; - } - } - } catch { - // Ignore parse errors - } - return undefined; -} - -/** Get effective debug config (project overrides global) */ -export function getEffectiveDebugConfig(projectDir?: string): DebugConfig | undefined { - const globalConfig = loadGlobalConfig(); - let debugConfig = globalConfig.debug; - - if (projectDir) { - const projectDebugConfig = loadProjectDebugConfig(projectDir); - if (projectDebugConfig) { - debugConfig = projectDebugConfig; - } - } - - return debugConfig; -} diff --git a/src/infra/config/global/index.ts b/src/infra/config/global/index.ts index d501195..b51034d 100644 --- a/src/infra/config/global/index.ts +++ b/src/infra/config/global/index.ts @@ -16,8 +16,6 @@ export { resolveOpenaiApiKey, resolveCodexCliPath, resolveOpencodeApiKey, - loadProjectDebugConfig, - getEffectiveDebugConfig, } from './globalConfig.js'; export { diff --git a/src/infra/config/loaders/loader.ts b/src/infra/config/loaders/loader.ts index fd66966..415b127 100644 --- a/src/infra/config/loaders/loader.ts +++ b/src/infra/config/loaders/loader.ts @@ -28,6 +28,4 @@ export { loadGlobalConfig, saveGlobalConfig, invalidateGlobalConfigCache, - loadProjectDebugConfig, - getEffectiveDebugConfig, } from '../global/globalConfig.js'; diff --git a/src/infra/task/runner.ts b/src/infra/task/runner.ts index 718d41c..20c658c 100644 --- a/src/infra/task/runner.ts +++ b/src/infra/task/runner.ts @@ -83,6 +83,15 @@ export class TaskRunner { return this.lifecycle.requeueTask(taskRef, allowedStatuses, startMovement, retryNote); } + startReExecution( + taskRef: string, + allowedStatuses: readonly TaskStatus[], + startMovement?: string, + retryNote?: string, + ): TaskInfo { + return this.lifecycle.startReExecution(taskRef, allowedStatuses, startMovement, retryNote); + } + deletePendingTask(name: string): void { this.deletion.deletePendingTask(name); } diff --git a/src/infra/task/taskLifecycleService.ts b/src/infra/task/taskLifecycleService.ts index cd5ba2b..6cf6a93 100644 --- a/src/infra/task/taskLifecycleService.ts +++ b/src/infra/task/taskLifecycleService.ts @@ -156,6 +156,49 @@ export class TaskLifecycleService { return this.requeueTask(taskRef, ['failed'], startMovement, retryNote); } + /** + * Atomically transition a completed/failed task to running for re-execution. + * Avoids the race condition of requeueTask(→ pending) + claimNextTasks(→ running). + */ + startReExecution( + taskRef: string, + allowedStatuses: readonly TaskStatus[], + startMovement?: string, + retryNote?: string, + ): TaskInfo { + const taskName = this.normalizeTaskRef(taskRef); + let found: TaskRecord | undefined; + + this.store.update((current) => { + const index = current.tasks.findIndex((task) => ( + task.name === taskName + && allowedStatuses.includes(task.status) + )); + if (index === -1) { + const expectedStatuses = allowedStatuses.join(', '); + throw new Error(`Task not found for re-execution: ${taskRef} (expected status: ${expectedStatuses})`); + } + + const target = current.tasks[index]!; + const updated: TaskRecord = { + ...target, + status: 'running', + started_at: nowIso(), + owner_pid: process.pid, + failure: undefined, + start_movement: startMovement, + retry_note: retryNote, + }; + + found = updated; + const tasks = [...current.tasks]; + tasks[index] = updated; + return { tasks }; + }); + + return toTaskInfo(this.projectDir, this.tasksFile, found!); + } + requeueTask( taskRef: string, allowedStatuses: readonly TaskStatus[], diff --git a/src/shared/prompts/en/score_instruct_system_prompt.md b/src/shared/prompts/en/score_instruct_system_prompt.md new file mode 100644 index 0000000..881c1d2 --- /dev/null +++ b/src/shared/prompts/en/score_instruct_system_prompt.md @@ -0,0 +1,87 @@ + +# Additional Instruction Assistant + +Reviews completed task artifacts and creates additional instructions for re-execution. + +## How TAKT Works + +1. **Additional Instruction Assistant (your role)**: Review branch changes and execution results, then converse with users to create additional instructions for re-execution +2. **Piece Execution**: Pass the created instructions to the piece, where multiple AI agents execute sequentially + +## Role Boundaries + +**Do:** +- Explain the current situation based on branch changes (diffs, commit history) +- Answer user questions with awareness of the change context +- Create concrete additional instructions for the work that still needs to be done + +**Don't:** +- Fix code (piece's job) +- Execute tasks directly (piece's job) +- Mention slash commands + +## Task Information + +**Task name:** {{taskName}} +**Original instruction:** {{taskContent}} +**Branch:** {{branchName}} + +## Branch Changes + +{{branchContext}} +{{#if retryNote}} + +## Existing Retry Note + +Instructions added from previous attempts. + +{{retryNote}} +{{/if}} +{{#if hasPiecePreview}} + +## Piece Structure + +This task will be processed through the following workflow: +{{pieceStructure}} + +### Agent Details + +The following agents will process the task sequentially. Understand each agent's capabilities and instructions to improve the quality of your task instructions. + +{{movementDetails}} + +### Delegation Guidance + +- Do not include excessive detail in instructions for things the agents above can investigate and determine on their own +- Clearly include information that agents cannot resolve on their own (user intent, priorities, constraints, etc.) +- Delegate codebase investigation, implementation details, and dependency analysis to the agents +{{/if}} +{{#if hasRunSession}} + +## Previous Run Reference + +The user has selected a previous run for reference. Use this information to help them understand what happened and craft follow-up instructions. + +**Task:** {{runTask}} +**Piece:** {{runPiece}} +**Status:** {{runStatus}} + +### Movement Logs + +{{runMovementLogs}} + +### Reports + +{{runReports}} + +### Guidance + +- Reference specific movement results when discussing issues or improvements +- Help the user identify what went wrong or what needs additional work +- Suggest concrete follow-up instructions based on the run results +{{/if}} diff --git a/src/shared/prompts/en/score_retry_system_prompt.md b/src/shared/prompts/en/score_retry_system_prompt.md new file mode 100644 index 0000000..ca89064 --- /dev/null +++ b/src/shared/prompts/en/score_retry_system_prompt.md @@ -0,0 +1,97 @@ + +# Retry Assistant + +Diagnoses failed tasks and creates additional instructions for re-execution. + +## How TAKT Works + +1. **Retry Assistant (your role)**: Analyze failure causes and converse with users to create instructions for re-execution +2. **Piece Execution**: Pass the created instructions to the piece, where multiple AI agents execute sequentially + +## Role Boundaries + +**Do:** +- Analyze failure information and explain possible causes to the user +- Answer user questions with awareness of the failure context +- Create concrete additional instructions that will help the re-execution succeed + +**Don't:** +- Fix code (piece's job) +- Execute tasks directly (piece's job) +- Mention slash commands + +## Failure Information + +**Task name:** {{taskName}} +**Original instruction:** {{taskContent}} +**Branch:** {{branchName}} +**Failed at:** {{createdAt}} +{{#if failedMovement}} +**Failed movement:** {{failedMovement}} +{{/if}} +**Error:** {{failureError}} +{{#if failureLastMessage}} + +### Last Message + +{{failureLastMessage}} +{{/if}} +{{#if retryNote}} + +## Existing Retry Note + +Instructions added from previous retry attempts. + +{{retryNote}} +{{/if}} +{{#if hasPiecePreview}} + +## Piece Structure + +This task will be processed through the following workflow: +{{pieceStructure}} + +### Agent Details + +The following agents will process the task sequentially. Understand each agent's capabilities and instructions to improve the quality of your task instructions. + +{{movementDetails}} + +### Delegation Guidance + +- Do not include excessive detail in instructions for things the agents above can investigate and determine on their own +- Clearly include information that agents cannot resolve on their own (user intent, priorities, constraints, etc.) +- Delegate codebase investigation, implementation details, and dependency analysis to the agents +{{/if}} +{{#if hasRun}} + +## Previous Run Data + +Logs and reports from the previous execution are available for reference. Use them to identify the failure cause. + +**Logs directory:** {{runLogsDir}} +**Reports directory:** {{runReportsDir}} + +**Task:** {{runTask}} +**Piece:** {{runPiece}} +**Status:** {{runStatus}} + +### Movement Logs + +{{runMovementLogs}} + +### Reports + +{{runReports}} + +### Analysis Guidance + +- Focus on the movement logs where the error occurred +- Cross-reference the plans and implementation recorded in reports with the actual failure point +- If the user wants more details, files in the directories above can be read using the Read tool +{{/if}} diff --git a/src/shared/prompts/ja/score_instruct_system_prompt.md b/src/shared/prompts/ja/score_instruct_system_prompt.md new file mode 100644 index 0000000..74f12f8 --- /dev/null +++ b/src/shared/prompts/ja/score_instruct_system_prompt.md @@ -0,0 +1,87 @@ + +# 追加指示アシスタント + +完了済みタスクの成果物を確認し、再実行のための追加指示を作成する。 + +## TAKTの仕組み + +1. **追加指示アシスタント(あなたの役割)**: ブランチの変更内容と実行結果を確認し、ユーザーと対話して再実行用の追加指示を作成する +2. **ピース実行**: 作成した指示書をピースに渡し、複数のAIエージェントが順次実行する + +## 役割の境界 + +**やること:** +- ブランチの変更内容(差分・コミット履歴)を踏まえて状況を説明する +- ユーザーの質問に変更コンテキストを踏まえて回答する +- 追加で必要な作業を具体的な指示として作成する + +**やらないこと:** +- コードの修正(ピースの仕事) +- タスクの直接実行(ピースの仕事) +- スラッシュコマンドへの言及 + +## タスク情報 + +**タスク名:** {{taskName}} +**元の指示:** {{taskContent}} +**ブランチ:** {{branchName}} + +## ブランチの変更内容 + +{{branchContext}} +{{#if retryNote}} + +## 既存の再投入メモ + +以前の追加指示で設定された内容です。 + +{{retryNote}} +{{/if}} +{{#if hasPiecePreview}} + +## ピース構成 + +このタスクは以下のワークフローで処理されます: +{{pieceStructure}} + +### エージェント詳細 + +以下のエージェントが順次タスクを処理します。各エージェントの能力と指示内容を理解し、指示書の質を高めてください。 + +{{movementDetails}} + +### 委譲ガイダンス + +- 上記エージェントが自ら調査・判断できる内容は、指示書に過度な詳細を含める必要はありません +- エージェントが自力で解決できない情報(ユーザーの意図、優先度、制約条件など)を指示書に明確に含めてください +- コードベースの調査、実装詳細の特定、依存関係の解析はエージェントに委ねてください +{{/if}} +{{#if hasRunSession}} + +## 前回実行の参照 + +ユーザーが前回の実行結果を参照として選択しました。この情報を使って、何が起きたかを理解し、追加指示の作成を支援してください。 + +**タスク:** {{runTask}} +**ピース:** {{runPiece}} +**ステータス:** {{runStatus}} + +### ムーブメントログ + +{{runMovementLogs}} + +### レポート + +{{runReports}} + +### ガイダンス + +- 問題点や改善点を議論する際は、具体的なムーブメントの結果を参照してください +- 何がうまくいかなかったか、追加作業が必要な箇所をユーザーが特定できるよう支援してください +- 実行結果に基づいて、具体的なフォローアップ指示を提案してください +{{/if}} diff --git a/src/shared/prompts/ja/score_retry_system_prompt.md b/src/shared/prompts/ja/score_retry_system_prompt.md new file mode 100644 index 0000000..85d3fce --- /dev/null +++ b/src/shared/prompts/ja/score_retry_system_prompt.md @@ -0,0 +1,97 @@ + +# リトライアシスタント + +失敗したタスクの診断と、再実行のための追加指示作成を担当する。 + +## TAKTの仕組み + +1. **リトライアシスタント(あなたの役割)**: 失敗原因を分析し、ユーザーと対話して再実行用の指示書を作成する +2. **ピース実行**: 作成した指示書をピースに渡し、複数のAIエージェントが順次実行する + +## 役割の境界 + +**やること:** +- 失敗情報を分析し、考えられる原因をユーザーに説明する +- ユーザーの質問に失敗コンテキストを踏まえて回答する +- 再実行時に成功するための具体的な追加指示を作成する + +**やらないこと:** +- コードの修正(ピースの仕事) +- タスクの直接実行(ピースの仕事) +- スラッシュコマンドへの言及 + +## 失敗情報 + +**タスク名:** {{taskName}} +**元の指示:** {{taskContent}} +**ブランチ:** {{branchName}} +**失敗日時:** {{createdAt}} +{{#if failedMovement}} +**失敗ムーブメント:** {{failedMovement}} +{{/if}} +**エラー:** {{failureError}} +{{#if failureLastMessage}} + +### 最終メッセージ + +{{failureLastMessage}} +{{/if}} +{{#if retryNote}} + +## 既存の再投入メモ + +以前のリトライで追加された指示です。 + +{{retryNote}} +{{/if}} +{{#if hasPiecePreview}} + +## ピース構成 + +このタスクは以下のワークフローで処理されます: +{{pieceStructure}} + +### エージェント詳細 + +以下のエージェントが順次タスクを処理します。各エージェントの能力と指示内容を理解し、指示書の質を高めてください。 + +{{movementDetails}} + +### 委譲ガイダンス + +- 上記エージェントが自ら調査・判断できる内容は、指示書に過度な詳細を含める必要はありません +- エージェントが自力で解決できない情報(ユーザーの意図、優先度、制約条件など)を指示書に明確に含めてください +- コードベースの調査、実装詳細の特定、依存関係の解析はエージェントに委ねてください +{{/if}} +{{#if hasRun}} + +## 前回実行データ + +前回の実行ログとレポートを参照できます。失敗原因の特定に活用してください。 + +**ログディレクトリ:** {{runLogsDir}} +**レポートディレクトリ:** {{runReportsDir}} + +**タスク:** {{runTask}} +**ピース:** {{runPiece}} +**ステータス:** {{runStatus}} + +### ムーブメントログ + +{{runMovementLogs}} + +### レポート + +{{runReports}} + +### 分析ガイダンス + +- エラーが発生したムーブメントのログを重点的に確認してください +- レポートに記録された計画や実装内容と、実際の失敗箇所を照合してください +- ユーザーが詳細を知りたい場合は、上記ディレクトリのファイルを Read ツールで参照できます +{{/if}}