diff --git a/docs/README.ja.md b/docs/README.ja.md index a57d610..a202680 100644 --- a/docs/README.ja.md +++ b/docs/README.ja.md @@ -94,6 +94,8 @@ takt hello **注意:** `--task` オプションを指定すると対話モードをスキップして直接タスク実行されます。Issue 参照(`#6`、`--issue`)は対話モードの初期入力として使用されます。 +対話開始時には `takt list` の履歴を自動取得し、`failed` / `interrupted` / `completed` の実行結果を `pieceContext` に注入して会話要約へ反映します。要約では `Worktree ID`、`開始/終了時刻`、`最終結果`、`失敗要約`、`ログ参照キー` を参照できます。`takt list` の取得に失敗しても対話は継続されます。 + **フロー:** 1. ピース選択 2. 対話モード選択(assistant / persona / quiet / passthrough) @@ -225,6 +227,8 @@ takt list --non-interactive --action delete --branch takt/my-branch --yes takt list --non-interactive --format json ``` +対話モードでは、上記の実行履歴(`failed` / `interrupted` / `completed`)を起動時に再利用し、失敗事例や中断済み実行を再作業対象として特定しやすくします。 + #### タスクディレクトリ運用(作成・実行・確認) 1. `takt add` を実行して `.takt/tasks.yaml` に pending レコードが作られることを確認する。 diff --git a/src/__tests__/addTask.test.ts b/src/__tests__/addTask.test.ts index ae79fbe..966353e 100644 --- a/src/__tests__/addTask.test.ts +++ b/src/__tests__/addTask.test.ts @@ -114,6 +114,7 @@ describe('addTask', () => { expect(task.task_dir).toBeTypeOf('string'); expect(readOrderContent(testDir, task.task_dir)).toContain('JWT認証を実装する'); expect(task.piece).toBe('default'); + expect(task.worktree).toBe(true); }); it('should include worktree settings when enabled', async () => { @@ -125,6 +126,7 @@ describe('addTask', () => { const task = loadTasks(testDir).tasks[0]!; expect(task.worktree).toBe('/custom/path'); expect(task.branch).toBe('feat/branch'); + expect(task.auto_pr).toBe(true); }); it('should create task from issue reference without interactive mode', async () => { diff --git a/src/__tests__/cli-routing-issue-resolve.test.ts b/src/__tests__/cli-routing-issue-resolve.test.ts index 1ba690b..b29af62 100644 --- a/src/__tests__/cli-routing-issue-resolve.test.ts +++ b/src/__tests__/cli-routing-issue-resolve.test.ts @@ -56,6 +56,22 @@ vi.mock('../features/interactive/index.js', () => ({ quietMode: vi.fn(), personaMode: vi.fn(), resolveLanguage: vi.fn(() => 'en'), + selectRun: vi.fn(() => null), + loadRunSessionContext: vi.fn(), + listRecentRuns: vi.fn(() => []), + normalizeTaskHistorySummary: vi.fn((items: unknown[]) => items), + dispatchConversationAction: vi.fn(async (result: { action: string }, handlers: Record unknown>) => { + return handlers[result.action](result); + }), +})); + +const mockListAllTaskItems = vi.fn(); +const mockIsStaleRunningTask = vi.fn(); +vi.mock('../infra/task/index.js', () => ({ + TaskRunner: vi.fn(() => ({ + listAllTaskItems: mockListAllTaskItems, + })), + isStaleRunningTask: (...args: unknown[]) => mockIsStaleRunningTask(...args), })); vi.mock('../infra/config/index.js', () => ({ @@ -110,6 +126,7 @@ const mockSelectRecentSession = vi.mocked(selectRecentSession); const mockLoadGlobalConfig = vi.mocked(loadGlobalConfig); const mockConfirm = vi.mocked(confirm); const mockIsDirectTask = vi.mocked(isDirectTask); +const mockTaskRunnerListAllTaskItems = vi.mocked(mockListAllTaskItems); function createMockIssue(number: number): GitHubIssue { return { @@ -133,6 +150,8 @@ beforeEach(() => { mockConfirm.mockResolvedValue(true); mockIsDirectTask.mockReturnValue(false); mockParseIssueNumbers.mockReturnValue([]); + mockTaskRunnerListAllTaskItems.mockReturnValue([]); + mockIsStaleRunningTask.mockReturnValue(false); }); describe('Issue resolution in routing', () => { @@ -262,6 +281,142 @@ describe('Issue resolution in routing', () => { }); }); + describe('task history injection', () => { + it('should include failed/completed/interrupted tasks in pieceContext for interactive mode', async () => { + const failedTask = { + kind: 'failed' as const, + name: 'failed-task', + createdAt: '2026-02-17T00:00:00.000Z', + filePath: '/project/.takt/tasks.yaml', + content: 'failed', + worktreePath: '/tmp/task/failed', + branch: 'takt/failed', + startedAt: '2026-02-17T00:00:00.000Z', + completedAt: '2026-02-17T00:10:00.000Z', + failure: { error: 'syntax error' }, + }; + const completedTask = { + kind: 'completed' as const, + name: 'completed-task', + createdAt: '2026-02-16T00:00:00.000Z', + filePath: '/project/.takt/tasks.yaml', + content: 'done', + worktreePath: '/tmp/task/completed', + branch: 'takt/completed', + startedAt: '2026-02-16T00:00:00.000Z', + completedAt: '2026-02-16T00:07:00.000Z', + }; + const runningTask = { + kind: 'running' as const, + name: 'running-task', + createdAt: '2026-02-15T00:00:00.000Z', + filePath: '/project/.takt/tasks.yaml', + content: 'running', + worktreePath: '/tmp/task/interrupted', + ownerPid: 555, + startedAt: '2026-02-15T00:00:00.000Z', + }; + mockTaskRunnerListAllTaskItems.mockReturnValue([failedTask, completedTask, runningTask]); + mockIsStaleRunningTask.mockReturnValue(true); + + // When + await executeDefaultAction('add feature'); + + // Then + expect(mockInteractiveMode).toHaveBeenCalledWith( + '/test/cwd', + 'add feature', + expect.objectContaining({ + taskHistory: expect.arrayContaining([ + expect.objectContaining({ + worktreeId: '/tmp/task/failed', + status: 'failed', + finalResult: 'failed', + logKey: 'takt/failed', + }), + expect.objectContaining({ + worktreeId: '/tmp/task/completed', + status: 'completed', + finalResult: 'completed', + logKey: 'takt/completed', + }), + expect.objectContaining({ + worktreeId: '/tmp/task/interrupted', + status: 'interrupted', + finalResult: 'interrupted', + logKey: '/tmp/task/interrupted', + }), + ]), + }), + undefined, + ); + }); + + it('should treat running tasks with no ownerPid as interrupted', async () => { + const runningTaskWithoutPid = { + kind: 'running' as const, + name: 'running-task-no-owner', + createdAt: '2026-02-15T00:00:00.000Z', + filePath: '/project/.takt/tasks.yaml', + content: 'running', + worktreePath: '/tmp/task/running-no-owner', + branch: 'takt/running-no-owner', + startedAt: '2026-02-15T00:00:00.000Z', + }; + mockTaskRunnerListAllTaskItems.mockReturnValue([runningTaskWithoutPid]); + mockIsStaleRunningTask.mockReturnValue(true); + + await executeDefaultAction('recover interrupted'); + + expect(mockIsStaleRunningTask).toHaveBeenCalledWith(undefined); + expect(mockInteractiveMode).toHaveBeenCalledWith( + '/test/cwd', + 'recover interrupted', + expect.objectContaining({ + taskHistory: expect.arrayContaining([ + expect.objectContaining({ + worktreeId: '/tmp/task/running-no-owner', + status: 'interrupted', + finalResult: 'interrupted', + logKey: 'takt/running-no-owner', + }), + ]), + }), + undefined, + ); + }); + + it('should continue interactive mode when task list retrieval fails', async () => { + mockTaskRunnerListAllTaskItems.mockImplementation(() => { + throw new Error('list failed'); + }); + + // When + await executeDefaultAction('fix issue'); + + // Then + expect(mockInteractiveMode).toHaveBeenCalledWith( + '/test/cwd', + 'fix issue', + expect.objectContaining({ taskHistory: [] }), + undefined, + ); + }); + + it('should pass empty taskHistory when task list is empty', async () => { + mockTaskRunnerListAllTaskItems.mockReturnValue([]); + + await executeDefaultAction('verify history'); + + expect(mockInteractiveMode).toHaveBeenCalledWith( + '/test/cwd', + 'verify history', + expect.objectContaining({ taskHistory: [] }), + undefined, + ); + }); + }); + describe('interactive mode cancel', () => { it('should not call selectAndExecuteTask when interactive mode is cancelled', async () => { // Given @@ -387,4 +542,21 @@ describe('Issue resolution in routing', () => { ); }); }); + + describe('run session reference', () => { + it('should not prompt run session reference in default interactive flow', async () => { + await executeDefaultAction(); + + expect(mockConfirm).not.toHaveBeenCalledWith( + "Reference a previous run's results?", + false, + ); + expect(mockInteractiveMode).toHaveBeenCalledWith( + '/test/cwd', + undefined, + expect.anything(), + undefined, + ); + }); + }); }); diff --git a/src/__tests__/instructMode.test.ts b/src/__tests__/instructMode.test.ts index b01d1d1..e9076a8 100644 --- a/src/__tests__/instructMode.test.ts +++ b/src/__tests__/instructMode.test.ts @@ -76,10 +76,12 @@ import { getProvider } from '../infra/providers/index.js'; import { runInstructMode } from '../features/tasks/list/instructMode.js'; import { selectOption } from '../shared/prompt/index.js'; import { info } from '../shared/ui/index.js'; +import { loadTemplate } from '../shared/prompts/index.js'; const mockGetProvider = vi.mocked(getProvider); const mockSelectOption = vi.mocked(selectOption); const mockInfo = vi.mocked(info); +const mockLoadTemplate = vi.mocked(loadTemplate); let savedIsTTY: boolean | undefined; let savedIsRaw: boolean | undefined; @@ -279,4 +281,34 @@ describe('runInstructMode', () => { expect(values).toContain('continue'); expect(values).not.toContain('create_issue'); }); + + it('should inject selected run context into system prompt variables', async () => { + setupRawStdin(toRawInputs(['/cancel'])); + setupMockProvider([]); + + const runSessionContext = { + task: 'Previous run task', + piece: 'default', + status: 'completed', + movementLogs: [ + { step: 'implement', persona: 'coder', status: 'completed', content: 'done' }, + ], + reports: [ + { filename: '00-plan.md', content: '# Plan' }, + ], + }; + + await runInstructMode('/project', 'branch context', 'feature-branch', undefined, runSessionContext); + + expect(mockLoadTemplate).toHaveBeenCalledWith( + 'score_interactive_system_prompt', + 'en', + expect.objectContaining({ + hasRunSession: true, + runTask: 'Previous run task', + runPiece: 'default', + runStatus: 'completed', + }), + ); + }); }); diff --git a/src/__tests__/interactive-summary.test.ts b/src/__tests__/interactive-summary.test.ts new file mode 100644 index 0000000..b999491 --- /dev/null +++ b/src/__tests__/interactive-summary.test.ts @@ -0,0 +1,102 @@ +/** + * Tests for task history context formatting in interactive summary. + */ + +import { describe, expect, it } from 'vitest'; + +import { + buildSummaryPrompt, + formatTaskHistorySummary, + type PieceContext, + type TaskHistorySummaryItem, +} from '../features/interactive/interactive.js'; + +describe('formatTaskHistorySummary', () => { + it('returns empty string when history is empty', () => { + expect(formatTaskHistorySummary([], 'en')).toBe(''); + }); + + it('formats task history with required fields', () => { + const history: TaskHistorySummaryItem[] = [ + { + worktreeId: 'wt-1', + status: 'interrupted', + startedAt: '2026-02-18T00:00:00.000Z', + completedAt: 'N/A', + finalResult: 'interrupted', + failureSummary: undefined, + logKey: 'log-1', + }, + { + worktreeId: 'wt-2', + status: 'failed', + startedAt: '2026-02-17T00:00:00.000Z', + completedAt: '2026-02-17T00:01:00.000Z', + finalResult: 'failed', + failureSummary: 'Syntax error in test', + logKey: 'log-2', + }, + ]; + + const result = formatTaskHistorySummary(history, 'en'); + expect(result).toContain('## Task execution history'); + expect(result).toContain('Worktree ID: wt-1'); + expect(result).toContain('Status: interrupted'); + expect(result).toContain('Failure summary: Syntax error in test'); + expect(result).toContain('Log key: log-2'); + }); + + it('normalizes empty start/end timestamps to N/A', () => { + const history: TaskHistorySummaryItem[] = [ + { + worktreeId: 'wt-3', + status: 'interrupted', + startedAt: '', + completedAt: '', + finalResult: 'interrupted', + failureSummary: undefined, + logKey: 'log-3', + }, + ]; + + const result = formatTaskHistorySummary(history, 'en'); + expect(result).toContain('Start/End: N/A / N/A'); + }); +}); + +describe('buildSummaryPrompt', () => { + it('includes taskHistory context when provided', () => { + const history: TaskHistorySummaryItem[] = [ + { + worktreeId: 'wt-1', + status: 'completed', + startedAt: '2026-02-10T00:00:00.000Z', + completedAt: '2026-02-10T00:00:30.000Z', + finalResult: 'completed', + failureSummary: undefined, + logKey: 'log-1', + }, + ]; + const pieceContext: PieceContext = { + name: 'my-piece', + description: 'desc', + pieceStructure: '', + movementPreviews: [], + taskHistory: history, + }; + + const summary = buildSummaryPrompt( + [{ role: 'user', content: 'Improve parser' }], + false, + 'en', + 'No transcript', + 'Conversation:', + pieceContext, + ); + + expect(summary).toContain('## Task execution history'); + expect(summary).toContain('Worktree ID: wt-1'); + expect(summary).toContain('Conversation:'); + expect(summary).toContain('User: Improve parser'); + }); +}); diff --git a/src/__tests__/interactive.test.ts b/src/__tests__/interactive.test.ts index 76e29e5..cd3b623 100644 --- a/src/__tests__/interactive.test.ts +++ b/src/__tests__/interactive.test.ts @@ -387,6 +387,63 @@ describe('interactiveMode', () => { ); }); + it('should include run session context in system prompt when provided', async () => { + // Given + setupRawStdin(toRawInputs(['hello', '/cancel'])); + const mockSetup = vi.fn(); + const mockCall = vi.fn(async () => ({ + persona: 'interactive', + status: 'done' as const, + content: 'AI response', + timestamp: new Date(), + })); + mockSetup.mockReturnValue({ call: mockCall }); + mockGetProvider.mockReturnValue({ setup: mockSetup, _call: mockCall } as unknown as ReturnType); + + const runSessionContext = { + task: 'Previous run task', + piece: 'default', + status: 'completed', + movementLogs: [{ step: 'implement', persona: 'coder', status: 'completed', content: 'Implementation done' }], + reports: [], + }; + + // When + await interactiveMode('/project', undefined, undefined, undefined, runSessionContext); + + // Then: system prompt should contain run session content + expect(mockSetup).toHaveBeenCalled(); + const setupArgs = mockSetup.mock.calls[0]![0] as { systemPrompt: string }; + expect(setupArgs.systemPrompt).toContain('Previous run task'); + expect(setupArgs.systemPrompt).toContain('default'); + expect(setupArgs.systemPrompt).toContain('completed'); + expect(setupArgs.systemPrompt).toContain('implement'); + expect(setupArgs.systemPrompt).toContain('Implementation done'); + expect(setupArgs.systemPrompt).toContain('Previous Run Reference'); + }); + + it('should not include run session section in system prompt when not provided', async () => { + // Given + setupRawStdin(toRawInputs(['hello', '/cancel'])); + const mockSetup = vi.fn(); + const mockCall = vi.fn(async () => ({ + persona: 'interactive', + status: 'done' as const, + content: 'AI response', + timestamp: new Date(), + })); + mockSetup.mockReturnValue({ call: mockCall }); + mockGetProvider.mockReturnValue({ setup: mockSetup, _call: mockCall } as unknown as ReturnType); + + // When + await interactiveMode('/project'); + + // Then: system prompt should NOT contain run session section + expect(mockSetup).toHaveBeenCalled(); + const setupArgs = mockSetup.mock.calls[0]![0] as { systemPrompt: string }; + expect(setupArgs.systemPrompt).not.toContain('Previous Run Reference'); + }); + it('should abort in-flight provider call on SIGINT during initial input', async () => { mockGetProvider.mockReturnValue({ setup: () => ({ diff --git a/src/__tests__/prompts.test.ts b/src/__tests__/prompts.test.ts index 55fb8bf..43785be 100644 --- a/src/__tests__/prompts.test.ts +++ b/src/__tests__/prompts.test.ts @@ -58,6 +58,19 @@ describe('variable substitution', () => { expect(result).toContain('You are the agent'); }); + it('replaces taskHistory variable in score_summary_system_prompt', () => { + const result = loadTemplate('score_summary_system_prompt', 'en', { + pieceInfo: true, + pieceName: 'piece', + pieceDescription: 'desc', + movementDetails: '', + conversation: 'Conversation: User: test', + taskHistory: '## Task execution history\n- Worktree ID: wt-1', + }); + expect(result).toContain('## Task execution history'); + expect(result).toContain('Worktree ID: wt-1'); + }); + it('replaces multiple different variables', () => { const result = loadTemplate('perform_judge_message', 'en', { agentOutput: 'test output', diff --git a/src/__tests__/runSelector.test.ts b/src/__tests__/runSelector.test.ts new file mode 100644 index 0000000..3cf2403 --- /dev/null +++ b/src/__tests__/runSelector.test.ts @@ -0,0 +1,91 @@ +/** + * Tests for runSelector + */ + +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('../shared/prompt/index.js', () => ({ + selectOption: vi.fn(), +})); + +vi.mock('../shared/i18n/index.js', () => ({ + getLabel: vi.fn((key: string) => key), +})); + +vi.mock('../shared/ui/index.js', () => ({ + info: vi.fn(), +})); + +vi.mock('../features/interactive/runSessionReader.js', () => ({ + listRecentRuns: vi.fn(), +})); + +import { selectOption } from '../shared/prompt/index.js'; +import { info } from '../shared/ui/index.js'; +import { listRecentRuns } from '../features/interactive/runSessionReader.js'; +import { selectRun } from '../features/interactive/runSelector.js'; + +const mockListRecentRuns = vi.mocked(listRecentRuns); +const mockSelectOption = vi.mocked(selectOption); +const mockInfo = vi.mocked(info); + +describe('selectRun', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('should return null and show message when no runs exist', async () => { + mockListRecentRuns.mockReturnValue([]); + + const result = await selectRun('/some/path', 'en'); + + expect(result).toBeNull(); + expect(mockInfo).toHaveBeenCalledWith('interactive.runSelector.noRuns'); + }); + + it('should present run options and return selected slug', async () => { + mockListRecentRuns.mockReturnValue([ + { slug: 'run-1', task: 'First task', piece: 'default', status: 'completed', startTime: '2026-02-01T10:00:00Z' }, + { slug: 'run-2', task: 'Second task', piece: 'custom', status: 'aborted', startTime: '2026-01-15T08:00:00Z' }, + ]); + mockSelectOption.mockResolvedValue('run-1'); + + const result = await selectRun('/some/path', 'en'); + + expect(result).toBe('run-1'); + expect(mockSelectOption).toHaveBeenCalledTimes(1); + + const callArgs = mockSelectOption.mock.calls[0]; + expect(callArgs[0]).toBe('interactive.runSelector.prompt'); + const options = callArgs[1]; + expect(options).toHaveLength(2); + expect(options[0].value).toBe('run-1'); + expect(options[0].label).toBe('First task'); + expect(options[1].value).toBe('run-2'); + expect(options[1].label).toBe('Second task'); + }); + + it('should return null when user cancels selection', async () => { + mockListRecentRuns.mockReturnValue([ + { slug: 'run-1', task: 'Task', piece: 'default', status: 'completed', startTime: '2026-02-01T00:00:00Z' }, + ]); + mockSelectOption.mockResolvedValue(null); + + const result = await selectRun('/some/path', 'en'); + + expect(result).toBeNull(); + }); + + it('should truncate long task labels', async () => { + const longTask = 'A'.repeat(100); + mockListRecentRuns.mockReturnValue([ + { slug: 'run-1', task: longTask, piece: 'default', status: 'completed', startTime: '2026-02-01T00:00:00Z' }, + ]); + mockSelectOption.mockResolvedValue('run-1'); + + await selectRun('/some/path', 'en'); + + const options = mockSelectOption.mock.calls[0][1]; + expect(options[0].label.length).toBeLessThanOrEqual(61); // 60 + '…' + }); +}); diff --git a/src/__tests__/runSessionReader.test.ts b/src/__tests__/runSessionReader.test.ts new file mode 100644 index 0000000..d878db4 --- /dev/null +++ b/src/__tests__/runSessionReader.test.ts @@ -0,0 +1,297 @@ +/** + * Tests for runSessionReader + */ + +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'; + +vi.mock('../infra/fs/session.js', () => ({ + loadNdjsonLog: vi.fn(), +})); + +import { loadNdjsonLog } from '../infra/fs/session.js'; +import { + listRecentRuns, + loadRunSessionContext, + formatRunSessionForPrompt, + type RunSessionContext, +} from '../features/interactive/runSessionReader.js'; + +const mockLoadNdjsonLog = vi.mocked(loadNdjsonLog); + +function createTmpDir(): string { + const dir = join(tmpdir(), `takt-test-runSessionReader-${Date.now()}-${Math.random().toString(36).slice(2)}`); + mkdirSync(dir, { recursive: true }); + return dir; +} + +function createRunDir( + cwd: string, + slug: string, + meta: Record, +): string { + const runDir = join(cwd, '.takt', 'runs', slug); + mkdirSync(join(runDir, 'logs'), { recursive: true }); + mkdirSync(join(runDir, 'reports'), { recursive: true }); + writeFileSync(join(runDir, 'meta.json'), JSON.stringify(meta), 'utf-8'); + return runDir; +} + +describe('listRecentRuns', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = createTmpDir(); + vi.clearAllMocks(); + }); + + it('should return empty array when .takt/runs does not exist', () => { + const result = listRecentRuns(tmpDir); + expect(result).toEqual([]); + }); + + it('should return empty array when no runs have meta.json', () => { + mkdirSync(join(tmpDir, '.takt', 'runs', 'empty-run'), { recursive: true }); + const result = listRecentRuns(tmpDir); + expect(result).toEqual([]); + }); + + it('should return runs sorted by startTime descending', () => { + createRunDir(tmpDir, 'run-old', { + task: 'Old task', + piece: 'default', + status: 'completed', + 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: 'New task', + piece: 'custom', + status: 'running', + startTime: '2026-02-01T00:00:00.000Z', + logsDirectory: '.takt/runs/run-new/logs', + reportDirectory: '.takt/runs/run-new/reports', + runSlug: 'run-new', + }); + + const result = listRecentRuns(tmpDir); + expect(result).toHaveLength(2); + expect(result[0].slug).toBe('run-new'); + expect(result[1].slug).toBe('run-old'); + }); + + it('should limit results to 10', () => { + for (let i = 0; i < 12; i++) { + const slug = `run-${String(i).padStart(2, '0')}`; + createRunDir(tmpDir, slug, { + task: `Task ${i}`, + piece: 'default', + status: 'completed', + startTime: `2026-01-${String(i + 1).padStart(2, '0')}T00:00:00.000Z`, + logsDirectory: `.takt/runs/${slug}/logs`, + reportDirectory: `.takt/runs/${slug}/reports`, + runSlug: slug, + }); + } + + const result = listRecentRuns(tmpDir); + expect(result).toHaveLength(10); + }); + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); + }); +}); + +describe('loadRunSessionContext', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = createTmpDir(); + vi.clearAllMocks(); + }); + + it('should throw when run does not exist', () => { + expect(() => loadRunSessionContext(tmpDir, 'nonexistent')).toThrow('Run not found: nonexistent'); + }); + + it('should load context with movement logs and reports', () => { + const slug = 'test-run'; + const runDir = createRunDir(tmpDir, slug, { + task: 'Test task', + piece: 'default', + status: 'completed', + startTime: '2026-02-01T00:00:00.000Z', + logsDirectory: `.takt/runs/${slug}/logs`, + reportDirectory: `.takt/runs/${slug}/reports`, + runSlug: slug, + }); + + // Create a log file + writeFileSync(join(runDir, 'logs', 'session-001.jsonl'), '{}', 'utf-8'); + + // Create a report file + writeFileSync(join(runDir, 'reports', '00-plan.md'), '# Plan\nDetails here', 'utf-8'); + + mockLoadNdjsonLog.mockReturnValue({ + task: 'Test task', + projectDir: '', + pieceName: 'default', + iterations: 1, + startTime: '2026-02-01T00:00:00.000Z', + status: 'completed', + history: [ + { + step: 'implement', + persona: 'coder', + instruction: 'Implement feature', + status: 'completed', + timestamp: '2026-02-01T00:01:00.000Z', + content: 'Implementation done', + }, + ], + }); + + const context = loadRunSessionContext(tmpDir, slug); + + expect(context.task).toBe('Test task'); + expect(context.piece).toBe('default'); + expect(context.status).toBe('completed'); + expect(context.movementLogs).toHaveLength(1); + expect(context.movementLogs[0].step).toBe('implement'); + expect(context.movementLogs[0].content).toBe('Implementation done'); + expect(context.reports).toHaveLength(1); + expect(context.reports[0].filename).toBe('00-plan.md'); + }); + + it('should truncate movement content to 500 characters', () => { + const slug = 'truncate-run'; + const runDir = createRunDir(tmpDir, slug, { + task: 'Truncate test', + piece: 'default', + status: 'completed', + startTime: '2026-02-01T00:00:00.000Z', + logsDirectory: `.takt/runs/${slug}/logs`, + reportDirectory: `.takt/runs/${slug}/reports`, + runSlug: slug, + }); + + writeFileSync(join(runDir, 'logs', 'session-001.jsonl'), '{}', 'utf-8'); + + const longContent = 'A'.repeat(600); + mockLoadNdjsonLog.mockReturnValue({ + task: 'Truncate test', + projectDir: '', + pieceName: 'default', + iterations: 1, + startTime: '2026-02-01T00:00:00.000Z', + status: 'completed', + history: [ + { + step: 'implement', + persona: 'coder', + instruction: 'Do it', + status: 'completed', + timestamp: '2026-02-01T00:01:00.000Z', + content: longContent, + }, + ], + }); + + const context = loadRunSessionContext(tmpDir, slug); + + expect(context.movementLogs[0].content.length).toBe(501); // 500 + '…' + expect(context.movementLogs[0].content.endsWith('…')).toBe(true); + }); + + it('should handle missing log files gracefully', () => { + const slug = 'no-logs-run'; + createRunDir(tmpDir, slug, { + task: 'No logs', + piece: 'default', + status: 'completed', + startTime: '2026-02-01T00:00:00.000Z', + logsDirectory: `.takt/runs/${slug}/logs`, + reportDirectory: `.takt/runs/${slug}/reports`, + runSlug: slug, + }); + + const context = loadRunSessionContext(tmpDir, slug); + expect(context.movementLogs).toEqual([]); + expect(context.reports).toEqual([]); + }); + + it('should exclude provider-events log files', () => { + const slug = 'provider-events-run'; + const runDir = createRunDir(tmpDir, slug, { + task: 'Provider events test', + piece: 'default', + status: 'completed', + startTime: '2026-02-01T00:00:00.000Z', + logsDirectory: `.takt/runs/${slug}/logs`, + reportDirectory: `.takt/runs/${slug}/reports`, + runSlug: slug, + }); + + // Only provider-events log file + writeFileSync(join(runDir, 'logs', 'session-001-provider-events.jsonl'), '{}', 'utf-8'); + + const context = loadRunSessionContext(tmpDir, slug); + expect(mockLoadNdjsonLog).not.toHaveBeenCalled(); + expect(context.movementLogs).toEqual([]); + }); + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); + }); +}); + +describe('formatRunSessionForPrompt', () => { + it('should format context into prompt variables', () => { + const ctx: RunSessionContext = { + task: 'Implement feature X', + piece: 'default', + status: 'completed', + movementLogs: [ + { step: 'plan', persona: 'architect', status: 'completed', content: 'Plan content' }, + { step: 'implement', persona: 'coder', status: 'completed', content: 'Code content' }, + ], + reports: [ + { filename: '00-plan.md', content: '# Plan\nDetails' }, + ], + }; + + const result = formatRunSessionForPrompt(ctx); + + expect(result.runTask).toBe('Implement feature X'); + expect(result.runPiece).toBe('default'); + expect(result.runStatus).toBe('completed'); + expect(result.runMovementLogs).toContain('plan'); + expect(result.runMovementLogs).toContain('architect'); + expect(result.runMovementLogs).toContain('Plan content'); + expect(result.runMovementLogs).toContain('implement'); + expect(result.runMovementLogs).toContain('Code content'); + expect(result.runReports).toContain('00-plan.md'); + expect(result.runReports).toContain('# Plan\nDetails'); + }); + + it('should handle empty logs and reports', () => { + const ctx: RunSessionContext = { + task: 'Empty task', + piece: 'default', + status: 'aborted', + movementLogs: [], + reports: [], + }; + + const result = formatRunSessionForPrompt(ctx); + + expect(result.runTask).toBe('Empty task'); + expect(result.runMovementLogs).toBe(''); + expect(result.runReports).toBe(''); + }); +}); diff --git a/src/__tests__/saveTaskFile.test.ts b/src/__tests__/saveTaskFile.test.ts index 54d0e91..9da02c7 100644 --- a/src/__tests__/saveTaskFile.test.ts +++ b/src/__tests__/saveTaskFile.test.ts @@ -105,8 +105,7 @@ describe('saveTaskFile', () => { }); describe('saveTaskFromInteractive', () => { - it('should save task with worktree settings when user confirms', async () => { - mockConfirm.mockResolvedValueOnce(true); + it('should always save task with worktree settings', async () => { mockPromptInput.mockResolvedValueOnce(''); mockPromptInput.mockResolvedValueOnce(''); mockConfirm.mockResolvedValueOnce(true); @@ -119,18 +118,22 @@ describe('saveTaskFromInteractive', () => { expect(task.auto_pr).toBe(true); }); - it('should save task without worktree settings when declined', async () => { + it('should keep worktree enabled even when auto-pr is declined', async () => { + mockPromptInput.mockResolvedValueOnce(''); + mockPromptInput.mockResolvedValueOnce(''); mockConfirm.mockResolvedValueOnce(false); await saveTaskFromInteractive(testDir, 'Task content'); const task = loadTasks(testDir).tasks[0]!; - expect(task.worktree).toBeUndefined(); + expect(task.worktree).toBe(true); expect(task.branch).toBeUndefined(); - expect(task.auto_pr).toBeUndefined(); + expect(task.auto_pr).toBe(false); }); it('should display piece info when specified', async () => { + mockPromptInput.mockResolvedValueOnce(''); + mockPromptInput.mockResolvedValueOnce(''); mockConfirm.mockResolvedValueOnce(false); await saveTaskFromInteractive(testDir, 'Task content', 'review'); @@ -139,6 +142,8 @@ describe('saveTaskFromInteractive', () => { }); it('should record issue number in tasks.yaml when issue option is provided', async () => { + mockPromptInput.mockResolvedValueOnce(''); + mockPromptInput.mockResolvedValueOnce(''); mockConfirm.mockResolvedValueOnce(false); await saveTaskFromInteractive(testDir, 'Fix login bug', 'default', { issue: 42 }); @@ -163,7 +168,6 @@ describe('saveTaskFromInteractive', () => { mockConfirm.mockResolvedValueOnce(true); mockPromptInput.mockResolvedValueOnce(''); mockPromptInput.mockResolvedValueOnce(''); - mockConfirm.mockResolvedValueOnce(true); mockConfirm.mockResolvedValueOnce(false); await saveTaskFromInteractive(testDir, 'Task content', 'default', { @@ -172,7 +176,7 @@ describe('saveTaskFromInteractive', () => { }); expect(mockConfirm).toHaveBeenNthCalledWith(1, 'Add this issue to tasks?', true); - expect(mockConfirm).toHaveBeenNthCalledWith(2, 'Create worktree?', true); + expect(mockConfirm).toHaveBeenNthCalledWith(2, 'Auto-create PR?', true); const task = loadTasks(testDir).tasks[0]!; expect(task.issue).toBe(42); expect(task.worktree).toBe(true); diff --git a/src/__tests__/selectorUtils.test.ts b/src/__tests__/selectorUtils.test.ts new file mode 100644 index 0000000..25f0f02 --- /dev/null +++ b/src/__tests__/selectorUtils.test.ts @@ -0,0 +1,60 @@ +/** + * Tests for selector shared utilities + */ + +import { describe, it, expect } from 'vitest'; +import { truncateForLabel, formatDateForSelector } from '../features/interactive/selectorUtils.js'; + +describe('truncateForLabel', () => { + it('should return text as-is when within max length', () => { + const result = truncateForLabel('Short text', 20); + + expect(result).toBe('Short text'); + }); + + it('should truncate text exceeding max length with ellipsis', () => { + const longText = 'A'.repeat(100); + + const result = truncateForLabel(longText, 60); + + expect(result).toHaveLength(61); // 60 + '…' + expect(result).toBe('A'.repeat(60) + '…'); + }); + + it('should replace newlines with spaces', () => { + const result = truncateForLabel('Line one\nLine two\nLine three', 50); + + expect(result).toBe('Line one Line two Line three'); + expect(result).not.toContain('\n'); + }); + + it('should trim surrounding whitespace', () => { + const result = truncateForLabel(' padded text ', 50); + + expect(result).toBe('padded text'); + }); + + it('should handle text exactly at max length', () => { + const exactText = 'A'.repeat(60); + + const result = truncateForLabel(exactText, 60); + + expect(result).toBe(exactText); + }); +}); + +describe('formatDateForSelector', () => { + it('should format date for English locale', () => { + const result = formatDateForSelector('2026-02-01T10:30:00Z', 'en'); + + expect(result).toBeTruthy(); + expect(typeof result).toBe('string'); + }); + + it('should format date for Japanese locale', () => { + const result = formatDateForSelector('2026-02-01T10:30:00Z', 'ja'); + + expect(result).toBeTruthy(); + expect(typeof result).toBe('string'); + }); +}); diff --git a/src/__tests__/task-process-alive.test.ts b/src/__tests__/task-process-alive.test.ts new file mode 100644 index 0000000..84a9eb5 --- /dev/null +++ b/src/__tests__/task-process-alive.test.ts @@ -0,0 +1,58 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import { isProcessAlive, isStaleRunningTask } from '../infra/task/process.js'; + +beforeEach(() => { + vi.restoreAllMocks(); +}); + +describe('process alive utility', () => { + it('returns true when process id exists', () => { + const mockKill = vi.spyOn(process, 'kill').mockImplementation(() => true); + + const result = isProcessAlive(process.pid); + + expect(mockKill).toHaveBeenCalledWith(process.pid, 0); + expect(result).toBe(true); + }); + + it('returns false when process does not exist', () => { + vi.spyOn(process, 'kill').mockImplementation(() => { + const error = new Error('No such process') as NodeJS.ErrnoException; + error.code = 'ESRCH'; + throw error; + }); + + expect(isProcessAlive(99999)).toBe(false); + }); + + it('treats permission errors as alive', () => { + vi.spyOn(process, 'kill').mockImplementation(() => { + const error = new Error('Permission denied') as NodeJS.ErrnoException; + error.code = 'EPERM'; + throw error; + }); + + expect(isProcessAlive(99999)).toBe(true); + }); + + it('throws for unexpected process errors', () => { + vi.spyOn(process, 'kill').mockImplementation(() => { + const error = new Error('Unknown') as NodeJS.ErrnoException; + error.code = 'EINVAL'; + throw error; + }); + + expect(() => isProcessAlive(99999)).toThrow('Unknown'); + }); + + it('returns true when stale check receives a live process id', () => { + vi.spyOn(process, 'kill').mockImplementation(() => true); + + expect(isStaleRunningTask(process.pid)).toBe(false); + }); + + it('returns true when stale check has no process id', () => { + expect(isStaleRunningTask(undefined)).toBe(true); + }); +}); diff --git a/src/__tests__/taskInstructionActions.test.ts b/src/__tests__/taskInstructionActions.test.ts index d880ae2..9e87db3 100644 --- a/src/__tests__/taskInstructionActions.test.ts +++ b/src/__tests__/taskInstructionActions.test.ts @@ -1,51 +1,40 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'; const { - mockAddTask, - mockCompleteTask, - mockFailTask, - mockExecuteTask, + mockRequeueTask, mockRunInstructMode, mockDispatchConversationAction, mockSelectPiece, + mockConfirm, + mockGetLabel, + mockResolveLanguage, + mockListRecentRuns, + mockSelectRun, + mockLoadRunSessionContext, } = vi.hoisted(() => ({ - mockAddTask: vi.fn(() => ({ - name: 'instruction-task', - content: 'instruction', - filePath: '/project/.takt/tasks.yaml', - createdAt: '2026-02-14T00:00:00.000Z', - status: 'pending', - data: { task: 'instruction' }, - })), - mockCompleteTask: vi.fn(), - mockFailTask: vi.fn(), - mockExecuteTask: vi.fn(), + mockRequeueTask: vi.fn(), mockRunInstructMode: vi.fn(), mockDispatchConversationAction: vi.fn(), mockSelectPiece: vi.fn(), + mockConfirm: vi.fn(), + mockGetLabel: vi.fn(), + mockResolveLanguage: vi.fn(() => 'en'), + mockListRecentRuns: vi.fn(() => []), + mockSelectRun: vi.fn(() => null), + mockLoadRunSessionContext: vi.fn(), })); vi.mock('../infra/task/index.js', () => ({ - createTempCloneForBranch: vi.fn(() => ({ path: '/tmp/clone', branch: 'takt/sample' })), - removeClone: vi.fn(), - removeCloneMeta: vi.fn(), detectDefaultBranch: vi.fn(() => 'main'), - autoCommitAndPush: vi.fn(() => ({ success: false, message: 'no changes' })), TaskRunner: class { - addTask(...args: unknown[]) { - return mockAddTask(...args); - } - completeTask(...args: unknown[]) { - return mockCompleteTask(...args); - } - failTask(...args: unknown[]) { - return mockFailTask(...args); + requeueTask(...args: unknown[]) { + return mockRequeueTask(...args); } }, })); vi.mock('../infra/config/index.js', () => ({ - loadGlobalConfig: vi.fn(() => ({ interactivePreviewMovements: false })), + loadGlobalConfig: vi.fn(() => ({ interactivePreviewMovements: 3, language: 'en' })), getPieceDescription: vi.fn(() => ({ name: 'default', description: 'desc', @@ -54,10 +43,6 @@ vi.mock('../infra/config/index.js', () => ({ })), })); -vi.mock('../features/tasks/execute/taskExecution.js', () => ({ - executeTask: (...args: unknown[]) => mockExecuteTask(...args), -})); - vi.mock('../features/tasks/list/instructMode.js', () => ({ runInstructMode: (...args: unknown[]) => mockRunInstructMode(...args), })); @@ -74,6 +59,21 @@ vi.mock('../features/interactive/actionDispatcher.js', () => ({ dispatchConversationAction: (...args: unknown[]) => mockDispatchConversationAction(...args), })); +vi.mock('../shared/prompt/index.js', () => ({ + confirm: (...args: unknown[]) => mockConfirm(...args), +})); + +vi.mock('../shared/i18n/index.js', () => ({ + getLabel: (...args: unknown[]) => mockGetLabel(...args), +})); + +vi.mock('../features/interactive/index.js', () => ({ + resolveLanguage: (...args: unknown[]) => mockResolveLanguage(...args), + listRecentRuns: (...args: unknown[]) => mockListRecentRuns(...args), + selectRun: (...args: unknown[]) => mockSelectRun(...args), + loadRunSessionContext: (...args: unknown[]) => mockLoadRunSessionContext(...args), +})); + vi.mock('../shared/ui/index.js', () => ({ info: vi.fn(), success: vi.fn(), @@ -91,17 +91,20 @@ vi.mock('../shared/utils/index.js', async (importOriginal) => ({ import { instructBranch } from '../features/tasks/list/taskActions.js'; -describe('instructBranch execute flow', () => { +describe('instructBranch requeue flow', () => { beforeEach(() => { vi.clearAllMocks(); mockSelectPiece.mockResolvedValue('default'); - mockRunInstructMode.mockResolvedValue({ type: 'execute', task: '追加して' }); - mockDispatchConversationAction.mockImplementation(async (_result, handlers) => handlers.execute({ task: '追加して' })); + mockRunInstructMode.mockResolvedValue({ action: 'execute', task: '追加指示A' }); + mockDispatchConversationAction.mockImplementation(async (_result, handlers) => handlers.execute({ task: '追加指示A' })); + mockConfirm.mockResolvedValue(true); + mockGetLabel.mockReturnValue("Reference a previous run's results?"); + mockResolveLanguage.mockReturnValue('en'); + mockListRecentRuns.mockReturnValue([]); + mockSelectRun.mockResolvedValue(null); }); - it('should record addTask and completeTask on success', async () => { - mockExecuteTask.mockResolvedValue(true); - + it('should requeue the same completed task instead of creating another task', async () => { const result = await instructBranch('/project', { kind: 'completed', name: 'done-task', @@ -110,18 +113,20 @@ describe('instructBranch execute flow', () => { content: 'done', branch: 'takt/done-task', worktreePath: '/project/.takt/worktrees/done-task', + data: { task: 'done', retry_note: '既存ノート' }, }); expect(result).toBe(true); - expect(mockAddTask).toHaveBeenCalledTimes(1); - expect(mockCompleteTask).toHaveBeenCalledTimes(1); - expect(mockFailTask).not.toHaveBeenCalled(); + expect(mockRequeueTask).toHaveBeenCalledWith( + 'done-task', + ['completed', 'failed'], + undefined, + '既存ノート\n\n追加指示A', + ); }); - it('should record addTask and failTask on failure', async () => { - mockExecuteTask.mockResolvedValue(false); - - const result = await instructBranch('/project', { + it('should set generated instruction as retry note when no existing note', async () => { + await instructBranch('/project', { kind: 'completed', name: 'done-task', createdAt: '2026-02-14T00:00:00.000Z', @@ -129,18 +134,26 @@ describe('instructBranch execute flow', () => { content: 'done', branch: 'takt/done-task', worktreePath: '/project/.takt/worktrees/done-task', + data: { task: 'done' }, }); - expect(result).toBe(false); - expect(mockAddTask).toHaveBeenCalledTimes(1); - expect(mockFailTask).toHaveBeenCalledTimes(1); - expect(mockCompleteTask).not.toHaveBeenCalled(); + expect(mockRequeueTask).toHaveBeenCalledWith( + 'done-task', + ['completed', 'failed'], + undefined, + '追加指示A', + ); }); - it('should record failTask when executeTask throws', async () => { - mockExecuteTask.mockRejectedValue(new Error('crashed')); + it('should load selected run context and pass it to instruct mode', async () => { + mockListRecentRuns.mockReturnValue([ + { slug: 'run-1', task: 'fix', piece: 'default', status: 'completed', startTime: '2026-02-18T00:00:00Z' }, + ]); + mockSelectRun.mockResolvedValue('run-1'); + const runContext = { task: 'fix', piece: 'default', status: 'completed', movementLogs: [], reports: [] }; + mockLoadRunSessionContext.mockReturnValue(runContext); - await expect(instructBranch('/project', { + await instructBranch('/project', { kind: 'completed', name: 'done-task', createdAt: '2026-02-14T00:00:00.000Z', @@ -148,10 +161,18 @@ describe('instructBranch execute flow', () => { content: 'done', branch: 'takt/done-task', worktreePath: '/project/.takt/worktrees/done-task', - })).rejects.toThrow('crashed'); + data: { task: 'done' }, + }); - expect(mockAddTask).toHaveBeenCalledTimes(1); - expect(mockFailTask).toHaveBeenCalledTimes(1); - expect(mockCompleteTask).not.toHaveBeenCalled(); + expect(mockConfirm).toHaveBeenCalledWith("Reference a previous run's results?", false); + expect(mockSelectRun).toHaveBeenCalledWith('/project', 'en'); + expect(mockLoadRunSessionContext).toHaveBeenCalledWith('/project', 'run-1'); + expect(mockRunInstructMode).toHaveBeenCalledWith( + '/project', + expect.any(String), + 'takt/done-task', + expect.anything(), + runContext, + ); }); }); diff --git a/src/__tests__/taskRetryActions.test.ts b/src/__tests__/taskRetryActions.test.ts index 981261d..10d6c23 100644 --- a/src/__tests__/taskRetryActions.test.ts +++ b/src/__tests__/taskRetryActions.test.ts @@ -6,7 +6,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; vi.mock('../shared/prompt/index.js', () => ({ selectOption: vi.fn(), - promptInput: vi.fn(), + confirm: vi.fn(), })); vi.mock('../shared/ui/index.js', () => ({ @@ -29,21 +29,44 @@ 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: [], + })), })); -import { selectOption, promptInput } from '../shared/prompt/index.js'; +vi.mock('../features/tasks/list/instructMode.js', () => ({ + runInstructMode: vi.fn(), +})); + +vi.mock('../features/interactive/index.js', () => ({ + resolveLanguage: vi.fn(() => 'en'), + listRecentRuns: vi.fn(() => []), + selectRun: vi.fn(() => null), + loadRunSessionContext: vi.fn(), +})); + +vi.mock('../shared/i18n/index.js', () => ({ + getLabel: vi.fn(() => "Reference a previous run's results?"), +})); + +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 mockPromptInput = vi.mocked(promptInput); +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; @@ -107,7 +130,8 @@ describe('retryFailedTask', () => { mockLoadGlobalConfig.mockReturnValue({ defaultPiece: 'default' }); mockLoadPieceByIdentifier.mockReturnValue(defaultPieceConfig); mockSelectOption.mockResolvedValue('implement'); - mockPromptInput.mockResolvedValue(''); + mockConfirm.mockResolvedValue(false); + mockRunInstructMode.mockResolvedValue({ action: 'execute', task: '追加指示A' }); const result = await retryFailedTask(task, tmpDir); @@ -117,6 +141,7 @@ describe('retryFailedTask', () => { 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'); }); it('should not add start_movement when initial movement is selected', async () => { @@ -125,13 +150,34 @@ describe('retryFailedTask', () => { mockLoadGlobalConfig.mockReturnValue({ defaultPiece: 'default' }); mockLoadPieceByIdentifier.mockReturnValue(defaultPieceConfig); mockSelectOption.mockResolvedValue('plan'); - mockPromptInput.mockResolvedValue(''); + mockConfirm.mockResolvedValue(false); + mockRunInstructMode.mockResolvedValue({ action: 'execute', task: '追加指示A' }); 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'); + }); + + 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: '既存ノート' }; + + mockLoadGlobalConfig.mockReturnValue({ defaultPiece: 'default' }); + mockLoadPieceByIdentifier.mockReturnValue(defaultPieceConfig); + mockSelectOption.mockResolvedValue('plan'); + mockConfirm.mockResolvedValue(false); + mockRunInstructMode.mockResolvedValue({ action: 'execute', task: '追加指示B' }); + + 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 () => { diff --git a/src/app/cli/routing.ts b/src/app/cli/routing.ts index d4626a2..02870e3 100644 --- a/src/app/cli/routing.ts +++ b/src/app/cli/routing.ts @@ -5,7 +5,7 @@ * pipeline mode, or interactive mode. */ -import { info, error, withProgress } from '../../shared/ui/index.js'; +import { info, error as logError, withProgress } from '../../shared/ui/index.js'; import { confirm } from '../../shared/prompt/index.js'; import { getErrorMessage } from '../../shared/utils/index.js'; import { getLabel } from '../../shared/i18n/index.js'; @@ -20,13 +20,14 @@ import { quietMode, personaMode, resolveLanguage, + dispatchConversationAction, type InteractiveModeResult, } from '../../features/interactive/index.js'; -import { dispatchConversationAction } from '../../features/interactive/actionDispatcher.js'; import { getPieceDescription, loadGlobalConfig } from '../../infra/config/index.js'; import { DEFAULT_PIECE_NAME } from '../../shared/constants.js'; import { program, resolvedCwd, pipelineMode } from './program.js'; import { resolveAgentOverrides, parseCreateWorktreeOption, isDirectTask } from './helpers.js'; +import { loadTaskHistory } from './taskHistory.js'; /** * Resolve issue references from CLI input. @@ -131,7 +132,7 @@ export async function executeDefaultAction(task?: string): Promise { initialInput = issueResult.initialInput; } } catch (e) { - error(getErrorMessage(e)); + logError(getErrorMessage(e)); process.exit(1); } @@ -160,6 +161,7 @@ export async function executeDefaultAction(task?: string): Promise { description: pieceDesc.description, pieceStructure: pieceDesc.pieceStructure, movementPreviews: pieceDesc.movementPreviews, + taskHistory: loadTaskHistory(resolvedCwd, lang), }; let result: InteractiveModeResult; diff --git a/src/app/cli/taskHistory.ts b/src/app/cli/taskHistory.ts new file mode 100644 index 0000000..9067ba1 --- /dev/null +++ b/src/app/cli/taskHistory.ts @@ -0,0 +1,55 @@ +import { isStaleRunningTask, TaskRunner } from '../../infra/task/index.js'; +import { + type TaskHistorySummaryItem, + normalizeTaskHistorySummary, +} from '../../features/interactive/index.js'; +import { getErrorMessage } from '../../shared/utils/index.js'; +import { error as logError } from '../../shared/ui/index.js'; + +/** + * Load and normalize task history for interactive context. + */ +function toTaskHistoryItems(cwd: string): TaskHistorySummaryItem[] { + const runner = new TaskRunner(cwd); + const tasks = runner.listAllTaskItems(); + + const historyItems: TaskHistorySummaryItem[] = []; + for (const task of tasks) { + if (task.kind === 'failed' || task.kind === 'completed') { + historyItems.push({ + worktreeId: task.worktreePath ?? task.name, + status: task.kind, + startedAt: task.startedAt ?? '', + completedAt: task.completedAt ?? '', + finalResult: task.kind, + failureSummary: task.failure?.error, + logKey: task.branch ?? task.worktreePath ?? task.name, + }); + continue; + } + + if (task.kind === 'running' && isStaleRunningTask(task.ownerPid)) { + historyItems.push({ + worktreeId: task.worktreePath ?? task.name, + status: 'interrupted', + startedAt: task.startedAt ?? '', + completedAt: task.completedAt ?? '', + finalResult: 'interrupted', + failureSummary: undefined, + logKey: task.branch ?? task.worktreePath ?? task.name, + }); + } + } + + return historyItems; +} + +export function loadTaskHistory(cwd: string, lang: 'en' | 'ja'): TaskHistorySummaryItem[] { + try { + return normalizeTaskHistorySummary(toTaskHistoryItems(cwd), lang); + } catch (err) { + logError(getErrorMessage(err)); + return []; + } +} + diff --git a/src/features/interactive/index.ts b/src/features/interactive/index.ts index 56bda3f..85baa1e 100644 --- a/src/features/interactive/index.ts +++ b/src/features/interactive/index.ts @@ -9,7 +9,9 @@ export { selectPostSummaryAction, formatMovementPreviews, formatSessionStatus, + normalizeTaskHistorySummary, type PieceContext, + type TaskHistorySummaryItem, type InteractiveModeResult, type InteractiveModeAction, } from './interactive.js'; @@ -19,3 +21,6 @@ export { selectRecentSession } from './sessionSelector.js'; 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 { dispatchConversationAction, type ConversationActionResult } from './actionDispatcher.js'; diff --git a/src/features/interactive/interactive-summary.ts b/src/features/interactive/interactive-summary.ts new file mode 100644 index 0000000..13c64ae --- /dev/null +++ b/src/features/interactive/interactive-summary.ts @@ -0,0 +1,263 @@ +/** + * Interactive summary helpers. + */ + +import { loadTemplate } from '../../shared/prompts/index.js'; +import { type MovementPreview } from '../../infra/config/index.js'; +import { selectOption } from '../../shared/prompt/index.js'; +import { blankLine, info } from '../../shared/ui/index.js'; + +type TaskHistoryLocale = 'en' | 'ja'; + +export interface ConversationMessage { + role: 'user' | 'assistant'; + content: string; +} + +export interface TaskHistorySummaryItem { + worktreeId: string; + status: 'completed' | 'failed' | 'interrupted'; + startedAt: string; + completedAt: string; + finalResult: string; + failureSummary: string | undefined; + logKey: string; +} + +export function formatMovementPreviews(previews: MovementPreview[], lang: TaskHistoryLocale): string { + return previews.map((p, i) => { + const toolsStr = p.allowedTools.length > 0 + ? p.allowedTools.join(', ') + : (lang === 'ja' ? 'なし' : 'None'); + const editStr = p.canEdit + ? (lang === 'ja' ? '可' : 'Yes') + : (lang === 'ja' ? '不可' : 'No'); + const personaLabel = lang === 'ja' ? 'ペルソナ' : 'Persona'; + const instructionLabel = lang === 'ja' ? 'インストラクション' : 'Instruction'; + const toolsLabel = lang === 'ja' ? 'ツール' : 'Tools'; + const editLabel = lang === 'ja' ? '編集' : 'Edit'; + + const lines = [ + `### ${i + 1}. ${p.name} (${p.personaDisplayName})`, + ]; + if (p.personaContent) { + lines.push(`**${personaLabel}:**`, p.personaContent); + } + if (p.instructionContent) { + lines.push(`**${instructionLabel}:**`, p.instructionContent); + } + lines.push(`**${toolsLabel}:** ${toolsStr}`, `**${editLabel}:** ${editStr}`); + return lines.join('\n'); + }).join('\n\n'); +} + +function normalizeDateTime(value: string): string { + return value.trim() === '' ? 'N/A' : value; +} + +function normalizeTaskStatus(status: TaskHistorySummaryItem['status'], lang: TaskHistoryLocale): string { + return status === 'completed' + ? (lang === 'ja' ? '完了' : 'completed') + : status === 'failed' + ? (lang === 'ja' ? '失敗' : 'failed') + : (lang === 'ja' ? '中断' : 'interrupted'); +} + +export function normalizeTaskHistorySummary( + items: TaskHistorySummaryItem[], + lang: TaskHistoryLocale, +): TaskHistorySummaryItem[] { + return items.map((task) => ({ + ...task, + startedAt: normalizeDateTime(task.startedAt), + completedAt: normalizeDateTime(task.completedAt), + finalResult: normalizeTaskStatus(task.status, lang), + })); +} + +function formatTaskHistoryItem(item: TaskHistorySummaryItem, lang: TaskHistoryLocale): string { + const statusLabel = normalizeTaskStatus(item.status, lang); + const failureSummaryLine = item.failureSummary + ? `${lang === 'ja' ? ' - 失敗要約' : ' - Failure summary'}: ${item.failureSummary}\n` + : ''; + const lines = [ + `- ${lang === 'ja' ? '実行ID' : 'Worktree ID'}: ${item.worktreeId}`, + ` - ${lang === 'ja' ? 'ステータス' : 'Status'}: ${statusLabel}`, + ` - ${lang === 'ja' ? '開始/終了' : 'Start/End'}: ${item.startedAt} / ${item.completedAt}`, + ` - ${lang === 'ja' ? '最終結果' : 'Final result'}: ${item.finalResult}`, + ` - ${lang === 'ja' ? 'ログ参照' : 'Log key'}: ${item.logKey}`, + failureSummaryLine, + ]; + return lines.join('\n').replace(/\n+$/, ''); +} + +export function formatTaskHistorySummary(taskHistory: TaskHistorySummaryItem[], lang: TaskHistoryLocale): string { + if (taskHistory.length === 0) { + return ''; + } + + const normalizedTaskHistory = normalizeTaskHistorySummary(taskHistory, lang); + const heading = lang === 'ja' + ? '## 実行履歴' + : '## Task execution history'; + const details = normalizedTaskHistory.map((item) => formatTaskHistoryItem(item, lang)).join('\n\n'); + return `${heading}\n${details}`; +} + +function buildTaskFromHistory(history: ConversationMessage[]): string { + return history + .map((msg) => `${msg.role === 'user' ? 'User' : 'Assistant'}: ${msg.content}`) + .join('\n\n'); +} + +export interface PieceContext { + /** Piece name (e.g. "minimal") */ + name: string; + /** Piece description */ + description: string; + /** Piece structure (numbered list of movements) */ + pieceStructure: string; + /** Movement previews (persona + instruction content for first N movements) */ + movementPreviews?: MovementPreview[]; + /** Recent task history for conversation context */ + taskHistory?: TaskHistorySummaryItem[]; +} + +export function buildSummaryPrompt( + history: ConversationMessage[], + hasSession: boolean, + lang: 'en' | 'ja', + noTranscriptNote: string, + conversationLabel: string, + pieceContext?: PieceContext, +): string { + let conversation = ''; + if (history.length > 0) { + const historyText = buildTaskFromHistory(history); + conversation = `${conversationLabel}\n${historyText}`; + } else if (hasSession) { + conversation = `${conversationLabel}\n${noTranscriptNote}`; + } else { + return ''; + } + + const hasPiece = !!pieceContext; + const hasPreview = !!pieceContext?.movementPreviews?.length; + const summaryMovementDetails = hasPreview + ? `\n### ${lang === 'ja' ? '処理するエージェント' : 'Processing Agents'}\n${formatMovementPreviews(pieceContext!.movementPreviews!, lang)}` + : ''; + const summaryTaskHistory = pieceContext?.taskHistory?.length + ? formatTaskHistorySummary(pieceContext.taskHistory, lang) + : ''; + + return loadTemplate('score_summary_system_prompt', lang, { + pieceInfo: hasPiece, + pieceName: pieceContext?.name ?? '', + pieceDescription: pieceContext?.description ?? '', + movementDetails: summaryMovementDetails, + taskHistory: summaryTaskHistory, + conversation, + }); +} + +export type PostSummaryAction = InteractiveModeAction | 'continue'; + +export type SummaryActionValue = 'execute' | 'create_issue' | 'save_task' | 'continue'; + +export interface SummaryActionOption { + label: string; + value: SummaryActionValue; +} + +export type SummaryActionLabels = { + execute: string; + createIssue?: string; + saveTask: string; + continue: string; +}; + +export const BASE_SUMMARY_ACTIONS: readonly SummaryActionValue[] = [ + 'execute', + 'save_task', + 'continue', +]; + +export type InteractiveModeAction = 'execute' | 'save_task' | 'create_issue' | 'cancel'; + +export interface InteractiveSummaryUIText { + actionPrompt: string; + actions: { + execute: string; + createIssue: string; + saveTask: string; + continue: string; + }; +} + +export function buildSummaryActionOptions( + labels: SummaryActionLabels, + append: readonly SummaryActionValue[] = [], +): SummaryActionOption[] { + const order = [...BASE_SUMMARY_ACTIONS, ...append]; + const seen = new Set(); + const options: SummaryActionOption[] = []; + + for (const action of order) { + if (seen.has(action)) { + continue; + } + seen.add(action); + + if (action === 'execute') { + options.push({ label: labels.execute, value: action }); + continue; + } + if (action === 'create_issue') { + if (labels.createIssue) { + options.push({ label: labels.createIssue, value: action }); + } + continue; + } + if (action === 'save_task') { + options.push({ label: labels.saveTask, value: action }); + continue; + } + options.push({ label: labels.continue, value: action }); + } + + return options; +} + +export function selectSummaryAction( + task: string, + proposedLabel: string, + actionPrompt: string, + options: SummaryActionOption[], +): Promise { + blankLine(); + info(proposedLabel); + console.log(task); + + return selectOption(actionPrompt, options); +} + +export function selectPostSummaryAction( + task: string, + proposedLabel: string, + ui: InteractiveSummaryUIText, +): Promise { + return selectSummaryAction( + task, + proposedLabel, + ui.actionPrompt, + buildSummaryActionOptions( + { + execute: ui.actions.execute, + createIssue: ui.actions.createIssue, + saveTask: ui.actions.saveTask, + continue: ui.actions.continue, + }, + ['create_issue'], + ), + ); +} diff --git a/src/features/interactive/interactive.ts b/src/features/interactive/interactive.ts index bc31775..63ef1b1 100644 --- a/src/features/interactive/interactive.ts +++ b/src/features/interactive/interactive.ts @@ -13,17 +13,20 @@ import type { Language } from '../../core/models/index.js'; import { type SessionState, - type MovementPreview, } from '../../infra/config/index.js'; -import { selectOption } from '../../shared/prompt/index.js'; -import { info, blankLine } from '../../shared/ui/index.js'; -import { loadTemplate } from '../../shared/prompts/index.js'; import { getLabel, getLabelObject } from '../../shared/i18n/index.js'; +import { loadTemplate } from '../../shared/prompts/index.js'; import { initializeSession, displayAndClearSessionState, runConversationLoop, } from './conversationLoop.js'; +import { + type PieceContext, + formatMovementPreviews, + type InteractiveModeAction, +} from './interactive-summary.js'; +import { type RunSessionContext, formatRunSessionForPrompt } from './runSessionReader.js'; /** Shape of interactive UI text */ export interface InteractiveUIText { @@ -57,7 +60,7 @@ export function formatSessionStatus(state: SessionState, lang: 'en' | 'ja'): str lines.push( getLabel('interactive.previousTask.error', lang, { error: state.errorMessage!, - }) + }), ); } else if (state.status === 'user_stopped') { lines.push(getLabel('interactive.previousTask.userStopped', lang)); @@ -67,7 +70,7 @@ export function formatSessionStatus(state: SessionState, lang: 'en' | 'ja'): str lines.push( getLabel('interactive.previousTask.piece', lang, { pieceName: state.pieceName, - }) + }), ); // Timestamp @@ -75,7 +78,7 @@ export function formatSessionStatus(state: SessionState, lang: 'en' | 'ja'): str lines.push( getLabel('interactive.previousTask.timestamp', lang, { timestamp, - }) + }), ); return lines.join('\n'); @@ -85,197 +88,19 @@ export function resolveLanguage(lang?: Language): 'en' | 'ja' { return lang === 'ja' ? 'ja' : 'en'; } -/** - * Format MovementPreview[] into a Markdown string for template injection. - * Each movement is rendered with its persona and instruction content. - */ -export function formatMovementPreviews(previews: MovementPreview[], lang: 'en' | 'ja'): string { - return previews.map((p, i) => { - const toolsStr = p.allowedTools.length > 0 - ? p.allowedTools.join(', ') - : (lang === 'ja' ? 'なし' : 'None'); - const editStr = p.canEdit - ? (lang === 'ja' ? '可' : 'Yes') - : (lang === 'ja' ? '不可' : 'No'); - const personaLabel = lang === 'ja' ? 'ペルソナ' : 'Persona'; - const instructionLabel = lang === 'ja' ? 'インストラクション' : 'Instruction'; - const toolsLabel = lang === 'ja' ? 'ツール' : 'Tools'; - const editLabel = lang === 'ja' ? '編集' : 'Edit'; - - const lines = [ - `### ${i + 1}. ${p.name} (${p.personaDisplayName})`, - ]; - if (p.personaContent) { - lines.push(`**${personaLabel}:**`, p.personaContent); - } - if (p.instructionContent) { - lines.push(`**${instructionLabel}:**`, p.instructionContent); - } - lines.push(`**${toolsLabel}:** ${toolsStr}`, `**${editLabel}:** ${editStr}`); - return lines.join('\n'); - }).join('\n\n'); -} - -export interface ConversationMessage { - role: 'user' | 'assistant'; - content: string; -} - -/** - * Build the final task description from conversation history for executeTask. - */ -function buildTaskFromHistory(history: ConversationMessage[]): string { - return history - .map((msg) => `${msg.role === 'user' ? 'User' : 'Assistant'}: ${msg.content}`) - .join('\n\n'); -} +/** Default toolset for interactive mode */ +export const DEFAULT_INTERACTIVE_TOOLS = ['Read', 'Glob', 'Grep', 'Bash', 'WebSearch', 'WebFetch']; /** * Build the summary prompt (used as both system prompt and user message). - * Renders the complete score_summary_system_prompt template with conversation data. - * Returns empty string if there is no conversation to summarize. */ -export function buildSummaryPrompt( - history: ConversationMessage[], - hasSession: boolean, - lang: 'en' | 'ja', - noTranscriptNote: string, - conversationLabel: string, - pieceContext?: PieceContext, -): string { - let conversation = ''; - if (history.length > 0) { - const historyText = buildTaskFromHistory(history); - conversation = `${conversationLabel}\n${historyText}`; - } else if (hasSession) { - conversation = `${conversationLabel}\n${noTranscriptNote}`; - } else { - return ''; - } - - const hasPiece = !!pieceContext; - const hasPreview = !!pieceContext?.movementPreviews?.length; - const summaryMovementDetails = hasPreview - ? `\n### ${lang === 'ja' ? '処理するエージェント' : 'Processing Agents'}\n${formatMovementPreviews(pieceContext!.movementPreviews!, lang)}` - : ''; - return loadTemplate('score_summary_system_prompt', lang, { - pieceInfo: hasPiece, - pieceName: pieceContext?.name ?? '', - pieceDescription: pieceContext?.description ?? '', - movementDetails: summaryMovementDetails, - conversation, - }); -} - -export type PostSummaryAction = InteractiveModeAction | 'continue'; - -export type SummaryActionValue = 'execute' | 'create_issue' | 'save_task' | 'continue'; - -export interface SummaryActionOption { - label: string; - value: SummaryActionValue; -} - -export type SummaryActionLabels = { - execute: string; - createIssue?: string; - saveTask: string; - continue: string; -}; - -export const BASE_SUMMARY_ACTIONS: readonly SummaryActionValue[] = [ - 'execute', - 'save_task', - 'continue', -]; - -export function buildSummaryActionOptions( - labels: SummaryActionLabels, - append: readonly SummaryActionValue[] = [], -): SummaryActionOption[] { - const order = [...BASE_SUMMARY_ACTIONS, ...append]; - const seen = new Set(); - const options: SummaryActionOption[] = []; - - for (const action of order) { - if (seen.has(action)) continue; - seen.add(action); - - if (action === 'execute') { - options.push({ label: labels.execute, value: action }); - continue; - } - if (action === 'create_issue') { - if (labels.createIssue) { - options.push({ label: labels.createIssue, value: action }); - } - continue; - } - if (action === 'save_task') { - options.push({ label: labels.saveTask, value: action }); - continue; - } - options.push({ label: labels.continue, value: action }); - } - - return options; -} - -export async function selectSummaryAction( - task: string, - proposedLabel: string, - actionPrompt: string, - options: SummaryActionOption[], -): Promise { - blankLine(); - info(proposedLabel); - console.log(task); - - return selectOption(actionPrompt, options); -} - -export async function selectPostSummaryAction( - task: string, - proposedLabel: string, - ui: InteractiveUIText, -): Promise { - return selectSummaryAction( - task, - proposedLabel, - ui.actionPrompt, - buildSummaryActionOptions( - { - execute: ui.actions.execute, - createIssue: ui.actions.createIssue, - saveTask: ui.actions.saveTask, - continue: ui.actions.continue, - }, - ['create_issue'], - ), - ); -} - -export type InteractiveModeAction = 'execute' | 'save_task' | 'create_issue' | 'cancel'; - -export interface InteractiveModeResult { - /** The action selected by the user */ - action: InteractiveModeAction; - /** The assembled task text (only meaningful when action is not 'cancel') */ - task: string; -} - -export interface PieceContext { - /** Piece name (e.g. "minimal") */ - name: string; - /** Piece description */ - description: string; - /** Piece structure (numbered list of movements) */ - pieceStructure: string; - /** Movement previews (persona + instruction content for first N movements) */ - movementPreviews?: MovementPreview[]; -} - -export const DEFAULT_INTERACTIVE_TOOLS = ['Read', 'Glob', 'Grep', 'Bash', 'WebSearch', 'WebFetch']; +export { + buildSummaryPrompt, + formatMovementPreviews, + type ConversationMessage, + type PieceContext, + type TaskHistorySummaryItem, +} from './interactive-summary.js'; /** * Run the interactive task input mode. @@ -291,6 +116,7 @@ export async function interactiveMode( initialInput?: string, pieceContext?: PieceContext, sessionId?: string, + runSessionContext?: RunSessionContext, ): Promise { const baseCtx = initializeSession(cwd, 'interactive'); const ctx = sessionId ? { ...baseCtx, sessionId } : baseCtx; @@ -298,10 +124,17 @@ export async function interactiveMode( displayAndClearSessionState(cwd, ctx.lang); const hasPreview = !!pieceContext?.movementPreviews?.length; + const hasRunSession = !!runSessionContext; + const runPromptVars = hasRunSession + ? formatRunSessionForPrompt(runSessionContext) + : { runTask: '', runPiece: '', runStatus: '', runMovementLogs: '', runReports: '' }; + const systemPrompt = loadTemplate('score_interactive_system_prompt', ctx.lang, { hasPiecePreview: hasPreview, pieceStructure: pieceContext?.pieceStructure ?? '', movementDetails: hasPreview ? formatMovementPreviews(pieceContext!.movementPreviews!, ctx.lang) : '', + hasRunSession, + ...runPromptVars, }); const policyContent = loadTemplate('score_interactive_policy', ctx.lang, {}); const ui = getLabelObject('interactive.ui', ctx.lang); @@ -327,3 +160,25 @@ export async function interactiveMode( introMessage: ui.intro, }, pieceContext, initialInput); } + +export { + type InteractiveModeAction, + type InteractiveSummaryUIText, + type PostSummaryAction, + type SummaryActionLabels, + type SummaryActionOption, + type SummaryActionValue, + selectPostSummaryAction, + buildSummaryActionOptions, + selectSummaryAction, + formatTaskHistorySummary, + normalizeTaskHistorySummary, + BASE_SUMMARY_ACTIONS, +} from './interactive-summary.js'; + +export interface InteractiveModeResult { + /** The action selected by the user */ + action: InteractiveModeAction; + /** The assembled task text (only meaningful when action is not 'cancel') */ + task: string; +} diff --git a/src/features/interactive/runSelector.ts b/src/features/interactive/runSelector.ts new file mode 100644 index 0000000..835050f --- /dev/null +++ b/src/features/interactive/runSelector.ts @@ -0,0 +1,49 @@ +/** + * Run selector for interactive mode + * + * Checks for recent runs and presents a selection UI + * using the same selectOption pattern as sessionSelector. + */ + +import { selectOption, type SelectOptionItem } from '../../shared/prompt/index.js'; +import { getLabel } from '../../shared/i18n/index.js'; +import { info } from '../../shared/ui/index.js'; +import { listRecentRuns, type RunSummary } from './runSessionReader.js'; +import { truncateForLabel, formatDateForSelector } from './selectorUtils.js'; + +/** Maximum label length for run task display */ +const MAX_TASK_LABEL_LENGTH = 60; + +/** + * Prompt user to select a run from recent runs. + * + * @returns Selected run slug, or null if no runs or cancelled + */ +export async function selectRun( + cwd: string, + lang: 'en' | 'ja', +): Promise { + const runs = listRecentRuns(cwd); + + if (runs.length === 0) { + info(getLabel('interactive.runSelector.noRuns', lang)); + return null; + } + + const options: SelectOptionItem[] = runs.map((run: RunSummary) => { + const label = truncateForLabel(run.task, MAX_TASK_LABEL_LENGTH); + const dateStr = formatDateForSelector(run.startTime, lang); + const description = `${dateStr} | ${run.piece} | ${run.status}`; + + return { + label, + value: run.slug, + description, + }; + }); + + const prompt = getLabel('interactive.runSelector.prompt', lang); + const selected = await selectOption(prompt, options); + + return selected; +} diff --git a/src/features/interactive/runSessionReader.ts b/src/features/interactive/runSessionReader.ts new file mode 100644 index 0000000..02c980b --- /dev/null +++ b/src/features/interactive/runSessionReader.ts @@ -0,0 +1,205 @@ +/** + * Run session reader for interactive mode + * + * Scans .takt/runs/ for recent runs, loads NDJSON logs and reports, + * and formats them for injection into the interactive system prompt. + */ + +import { existsSync, readdirSync, readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { loadNdjsonLog } from '../../infra/fs/index.js'; +import type { SessionLog } from '../../shared/utils/index.js'; + +/** Maximum number of runs to return from listing */ +const MAX_RUNS = 10; + +/** Maximum character length for movement log content */ +const MAX_CONTENT_LENGTH = 500; + +/** Summary of a run for selection UI */ +export interface RunSummary { + readonly slug: string; + readonly task: string; + readonly piece: string; + readonly status: string; + readonly startTime: string; +} + +/** A single movement log entry for display */ +interface MovementLogEntry { + readonly step: string; + readonly persona: string; + readonly status: string; + readonly content: string; +} + +/** A report file entry */ +interface ReportEntry { + readonly filename: string; + readonly content: string; +} + +/** Full context loaded from a run for prompt injection */ +export interface RunSessionContext { + readonly task: string; + readonly piece: string; + readonly status: string; + readonly movementLogs: readonly MovementLogEntry[]; + readonly reports: readonly ReportEntry[]; +} + +interface MetaJson { + readonly task: string; + readonly piece: string; + readonly status: string; + readonly startTime: string; + readonly logsDirectory: string; + readonly reportDirectory: string; + readonly runSlug: string; +} + +function truncateContent(content: string, maxLength: number): string { + if (content.length <= maxLength) { + return content; + } + return content.slice(0, maxLength) + '…'; +} + +function parseMetaJson(metaPath: string): MetaJson | null { + if (!existsSync(metaPath)) { + return null; + } + const raw = readFileSync(metaPath, 'utf-8'); + return JSON.parse(raw) as MetaJson; +} + +function buildMovementLogs(sessionLog: SessionLog): MovementLogEntry[] { + return sessionLog.history.map((entry) => ({ + step: entry.step, + persona: entry.persona, + status: entry.status, + content: truncateContent(entry.content, MAX_CONTENT_LENGTH), + })); +} + +function loadReports(reportsDir: string): ReportEntry[] { + if (!existsSync(reportsDir)) { + return []; + } + + const files = readdirSync(reportsDir).filter((f) => f.endsWith('.md')).sort(); + return files.map((filename) => ({ + filename, + content: readFileSync(join(reportsDir, filename), 'utf-8'), + })); +} + +function findSessionLogFile(logsDir: string): string | null { + if (!existsSync(logsDir)) { + return null; + } + + const files = readdirSync(logsDir).filter( + (f) => f.endsWith('.jsonl') && !f.includes('-provider-events'), + ); + + const first = files[0]; + if (!first) { + return null; + } + + return join(logsDir, first); +} + +/** + * List recent runs sorted by startTime descending. + */ +export function listRecentRuns(cwd: string): RunSummary[] { + const runsDir = join(cwd, '.takt', 'runs'); + if (!existsSync(runsDir)) { + return []; + } + + const entries = readdirSync(runsDir, { withFileTypes: true }); + const summaries: RunSummary[] = []; + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + + const metaPath = join(runsDir, entry.name, 'meta.json'); + const meta = parseMetaJson(metaPath); + if (!meta) continue; + + summaries.push({ + slug: entry.name, + task: meta.task, + piece: meta.piece, + status: meta.status, + startTime: meta.startTime, + }); + } + + summaries.sort((a, b) => b.startTime.localeCompare(a.startTime)); + return summaries.slice(0, MAX_RUNS); +} + +/** + * Load full run session context for prompt injection. + */ +export function loadRunSessionContext(cwd: string, slug: string): RunSessionContext { + const metaPath = join(cwd, '.takt', 'runs', slug, 'meta.json'); + const meta = parseMetaJson(metaPath); + if (!meta) { + throw new Error(`Run not found: ${slug}`); + } + + const logsDir = join(cwd, meta.logsDirectory); + const logFile = findSessionLogFile(logsDir); + + let movementLogs: MovementLogEntry[] = []; + if (logFile) { + const sessionLog = loadNdjsonLog(logFile); + if (sessionLog) { + movementLogs = buildMovementLogs(sessionLog); + } + } + + const reportsDir = join(cwd, meta.reportDirectory); + const reports = loadReports(reportsDir); + + return { + task: meta.task, + piece: meta.piece, + status: meta.status, + movementLogs, + reports, + }; +} + +/** + * Format run session context into a text block for the system prompt. + */ +export function formatRunSessionForPrompt(ctx: RunSessionContext): { + runTask: string; + runPiece: string; + runStatus: string; + runMovementLogs: string; + runReports: string; +} { + const logLines = ctx.movementLogs.map((log) => { + const header = `### ${log.step} (${log.persona}) — ${log.status}`; + return `${header}\n${log.content}`; + }); + + const reportLines = ctx.reports.map((report) => { + return `### ${report.filename}\n${report.content}`; + }); + + return { + runTask: ctx.task, + runPiece: ctx.piece, + runStatus: ctx.status, + runMovementLogs: logLines.join('\n\n'), + runReports: reportLines.join('\n\n'), + }; +} diff --git a/src/features/interactive/selectorUtils.ts b/src/features/interactive/selectorUtils.ts new file mode 100644 index 0000000..3ade78d --- /dev/null +++ b/src/features/interactive/selectorUtils.ts @@ -0,0 +1,27 @@ +/** + * Shared utilities for selector UI components. + */ + +/** + * Truncate text to a single line with a maximum length for display as a label. + */ +export function truncateForLabel(text: string, maxLength: number): string { + const singleLine = text.replace(/\n/g, ' ').trim(); + if (singleLine.length <= maxLength) { + return singleLine; + } + return singleLine.slice(0, maxLength) + '…'; +} + +/** + * Format a date string for display in selector options. + */ +export function formatDateForSelector(dateStr: string, lang: 'en' | 'ja'): string { + const date = new Date(dateStr); + return date.toLocaleString(lang === 'ja' ? 'ja-JP' : 'en-US', { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit', + }); +} diff --git a/src/features/interactive/sessionSelector.ts b/src/features/interactive/sessionSelector.ts index f353a70..a5a141a 100644 --- a/src/features/interactive/sessionSelector.ts +++ b/src/features/interactive/sessionSelector.ts @@ -9,6 +9,7 @@ import { loadSessionIndex, extractLastAssistantResponse } from '../../infra/clau import { selectOption, type SelectOptionItem } from '../../shared/prompt/index.js'; import { getLabel } from '../../shared/i18n/index.js'; import { info } from '../../shared/ui/index.js'; +import { truncateForLabel, formatDateForSelector } from './selectorUtils.js'; /** Maximum number of sessions to display */ const MAX_DISPLAY_SESSIONS = 10; @@ -16,30 +17,6 @@ const MAX_DISPLAY_SESSIONS = 10; /** Maximum length for last response preview */ const MAX_RESPONSE_PREVIEW_LENGTH = 200; -/** - * Format a modified date for display. - */ -function formatModifiedDate(modified: string, lang: 'en' | 'ja'): string { - const date = new Date(modified); - return date.toLocaleString(lang === 'ja' ? 'ja-JP' : 'en-US', { - month: 'short', - day: 'numeric', - hour: '2-digit', - minute: '2-digit', - }); -} - -/** - * Truncate a single-line string for use as a label. - */ -function truncateForLabel(text: string, maxLength: number): string { - const singleLine = text.replace(/\n/g, ' ').trim(); - if (singleLine.length <= maxLength) { - return singleLine; - } - return singleLine.slice(0, maxLength) + '…'; -} - /** * Prompt user to select from recent Claude Code sessions. * @@ -70,7 +47,7 @@ export async function selectRecentSession( for (const session of displaySessions) { const label = truncateForLabel(session.firstPrompt, 60); - const dateStr = formatModifiedDate(session.modified, lang); + const dateStr = formatDateForSelector(session.modified, lang); const messagesStr = getLabel('interactive.sessionSelector.messages', lang, { count: String(session.messageCount), }); diff --git a/src/features/tasks/add/index.ts b/src/features/tasks/add/index.ts index 3fe042f..79599f7 100644 --- a/src/features/tasks/add/index.ts +++ b/src/features/tasks/add/index.ts @@ -125,11 +125,6 @@ export async function createIssueAndSaveTask(cwd: string, task: string, piece?: } async function promptWorktreeSettings(): Promise { - const useWorktree = await confirm('Create worktree?', true); - if (!useWorktree) { - return {}; - } - const customPath = await promptInput('Worktree path (Enter for auto)'); const worktree: boolean | string = customPath || true; diff --git a/src/features/tasks/list/instructMode.ts b/src/features/tasks/list/instructMode.ts index 8a8c8a3..992d037 100644 --- a/src/features/tasks/list/instructMode.ts +++ b/src/features/tasks/list/instructMode.ts @@ -19,6 +19,7 @@ import { selectSummaryAction, type PieceContext, } from '../../interactive/interactive.js'; +import { type RunSessionContext, formatRunSessionForPrompt } from '../../interactive/runSessionReader.js'; import { loadTemplate } from '../../../shared/prompts/index.js'; import { getLabelObject } from '../../../shared/i18n/index.js'; import { loadGlobalConfig } from '../../../infra/config/index.js'; @@ -68,6 +69,7 @@ export async function runInstructMode( branchContext: string, branchName: string, pieceContext?: PieceContext, + runSessionContext?: RunSessionContext, ): Promise { const globalConfig = loadGlobalConfig(); const lang = resolveLanguage(globalConfig.language); @@ -83,10 +85,17 @@ 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' diff --git a/src/features/tasks/list/requeueHelpers.ts b/src/features/tasks/list/requeueHelpers.ts new file mode 100644 index 0000000..c1b045d --- /dev/null +++ b/src/features/tasks/list/requeueHelpers.ts @@ -0,0 +1,43 @@ +import { confirm } from '../../../shared/prompt/index.js'; +import { getLabel } from '../../../shared/i18n/index.js'; +import { + selectRun, + loadRunSessionContext, + listRecentRuns, + type RunSessionContext, +} from '../../interactive/index.js'; + +export 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}`; +} + +export async function selectRunSessionContext( + projectDir: string, + lang: 'en' | 'ja', +): Promise { + if (listRecentRuns(projectDir).length === 0) { + return undefined; + } + + const shouldReferenceRun = await confirm( + getLabel('interactive.runSelector.confirm', lang), + false, + ); + if (!shouldReferenceRun) { + return undefined; + } + + const selectedSlug = await selectRun(projectDir, lang); + if (!selectedSlug) { + return undefined; + } + + return loadRunSessionContext(projectDir, selectedSlug); +} diff --git a/src/features/tasks/list/taskDiffActions.ts b/src/features/tasks/list/taskDiffActions.ts index 787fa8b..ef9d0cd 100644 --- a/src/features/tasks/list/taskDiffActions.ts +++ b/src/features/tasks/list/taskDiffActions.ts @@ -65,7 +65,7 @@ export async function showDiffAndPromptActionForTask( `Action for ${branch}:`, [ { label: 'View diff', value: 'diff', description: 'Show full diff in pager' }, - { label: 'Instruct', value: 'instruct', description: 'Give additional instructions via temp clone' }, + { label: 'Instruct', value: 'instruct', description: 'Craft additional instructions and requeue this task' }, { label: 'Try merge', value: 'try', description: 'Squash merge (stage changes without commit)' }, { label: 'Merge & cleanup', value: 'merge', description: 'Merge and delete branch' }, { label: 'Delete', value: 'delete', description: 'Discard changes, delete branch' }, diff --git a/src/features/tasks/list/taskInstructionActions.ts b/src/features/tasks/list/taskInstructionActions.ts index 59431a7..9561698 100644 --- a/src/features/tasks/list/taskInstructionActions.ts +++ b/src/features/tasks/list/taskInstructionActions.ts @@ -1,23 +1,19 @@ import { execFileSync } from 'node:child_process'; import { - createTempCloneForBranch, - removeClone, - removeCloneMeta, TaskRunner, } from '../../../infra/task/index.js'; import { loadGlobalConfig, getPieceDescription } from '../../../infra/config/index.js'; -import { info, success, error as logError } from '../../../shared/ui/index.js'; +import { info, success } from '../../../shared/ui/index.js'; import { createLogger, getErrorMessage } from '../../../shared/utils/index.js'; -import { executeTask } from '../execute/taskExecution.js'; import type { TaskExecutionOptions } from '../execute/types.js'; -import { buildBooleanTaskResult, persistTaskError, persistTaskResult } from '../execute/taskResultHandler.js'; import { runInstructMode } from './instructMode.js'; -import { saveTaskFile } from '../add/index.js'; import { selectPiece } from '../../pieceSelection/index.js'; import { dispatchConversationAction } from '../../interactive/actionDispatcher.js'; import type { PieceContext } from '../../interactive/interactive.js'; -import { type BranchActionTarget, resolveTargetBranch, resolveTargetWorktreePath } from './taskActionTarget.js'; -import { detectDefaultBranch, autoCommitAndPush } from '../../../infra/task/index.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'; const log = createLogger('list-tasks'); @@ -70,10 +66,13 @@ function getBranchContext(projectDir: string, branch: string): string { export async function instructBranch( projectDir: string, target: BranchActionTarget, - options?: TaskExecutionOptions, + _options?: TaskExecutionOptions, ): Promise { + if (!('kind' in target)) { + throw new Error('Instruct requeue requires a task target.'); + } + const branch = resolveTargetBranch(target); - const worktreePath = resolveTargetWorktreePath(target); const selectedPiece = await selectPiece(projectDir); if (!selectedPiece) { @@ -90,96 +89,32 @@ export async function instructBranch( movementPreviews: pieceDesc.movementPreviews, }; + const lang = resolveLanguage(globalConfig.language); + const runSessionContext = await selectRunSessionContext(projectDir, lang); + const branchContext = getBranchContext(projectDir, branch); - const result = await runInstructMode(projectDir, branchContext, branch, pieceContext); + const result = await runInstructMode(projectDir, branchContext, branch, pieceContext, runSessionContext); + + const requeueWithInstruction = async (instruction: string): Promise => { + const runner = new TaskRunner(projectDir); + 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', { + name: target.name, + branch, + piece: selectedPiece, + }); + return true; + }; return dispatchConversationAction(result, { cancel: () => { info('Cancelled'); return false; }, - save_task: async ({ task }) => { - const created = await saveTaskFile(projectDir, task, { - piece: selectedPiece, - worktree: true, - branch, - autoPr: false, - }); - success(`Task saved: ${created.taskName}`); - info(` Branch: ${branch}`); - log.info('Task saved from instruct mode', { branch, piece: selectedPiece }); - return true; - }, - execute: async ({ task }) => { - log.info('Instructing branch via temp clone', { branch, piece: selectedPiece }); - info(`Running instruction on ${branch}...`); - - const clone = createTempCloneForBranch(projectDir, branch); - const fullInstruction = branchContext - ? `${branchContext}## 追加指示\n${task}` - : task; - - const runner = new TaskRunner(projectDir); - const taskRecord = runner.addTask(fullInstruction, { - piece: selectedPiece, - worktree: true, - branch, - auto_pr: false, - ...(worktreePath ? { worktree_path: worktreePath } : {}), - }); - const startedAt = new Date().toISOString(); - - try { - const taskSuccess = await executeTask({ - task: fullInstruction, - cwd: clone.path, - pieceIdentifier: selectedPiece, - projectCwd: projectDir, - agentOverrides: options, - }); - - const completedAt = new Date().toISOString(); - const taskResult = buildBooleanTaskResult({ - task: taskRecord, - taskSuccess, - successResponse: 'Instruction completed', - failureResponse: 'Instruction failed', - startedAt, - completedAt, - branch, - ...(worktreePath ? { worktreePath } : {}), - }); - persistTaskResult(runner, taskResult, { emitStatusLog: false }); - - if (taskSuccess) { - const commitResult = autoCommitAndPush(clone.path, task, projectDir); - if (commitResult.success && commitResult.commitHash) { - success(`Auto-committed & pushed: ${commitResult.commitHash}`); - } else if (!commitResult.success) { - logError(`Auto-commit failed: ${commitResult.message}`); - } - - success(`Instruction completed on ${branch}`); - log.info('Instruction completed', { branch }); - } else { - logError(`Instruction failed on ${branch}`); - log.error('Instruction failed', { branch }); - } - - return taskSuccess; - } catch (err) { - const completedAt = new Date().toISOString(); - persistTaskError(runner, taskRecord, startedAt, completedAt, err, { - emitStatusLog: false, - responsePrefix: 'Instruction failed: ', - }); - logError(`Instruction failed on ${branch}`); - log.error('Instruction crashed', { branch, error: getErrorMessage(err) }); - throw err; - } finally { - removeClone(clone.path); - removeCloneMeta(projectDir, branch); - } - }, + execute: async ({ task }) => requeueWithInstruction(task), + save_task: async ({ task }) => requeueWithInstruction(task), }); } diff --git a/src/features/tasks/list/taskRetryActions.ts b/src/features/tasks/list/taskRetryActions.ts index d840787..3c45dcd 100644 --- a/src/features/tasks/list/taskRetryActions.ts +++ b/src/features/tasks/list/taskRetryActions.ts @@ -7,11 +7,16 @@ import type { TaskListItem } from '../../../infra/task/index.js'; import { TaskRunner } from '../../../infra/task/index.js'; -import { loadPieceByIdentifier, loadGlobalConfig } from '../../../infra/config/index.js'; -import { selectOption, promptInput } from '../../../shared/prompt/index.js'; +import { loadPieceByIdentifier, loadGlobalConfig, getPieceDescription } from '../../../infra/config/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 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'; const log = createLogger('list-tasks'); @@ -53,6 +58,38 @@ 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 buildRetryBranchContext(task: TaskListItem): string { + const lines = [ + '## 失敗情報', + `- タスク名: ${task.name}`, + `- 失敗日時: ${task.createdAt}`, + ]; + if (task.failure?.movement) { + lines.push(`- 失敗ムーブメント: ${task.failure.movement}`); + } + if (task.failure?.error) { + lines.push(`- エラー: ${task.failure.error}`); + } + 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`; +} + /** * Retry a failed task. * @@ -62,9 +99,14 @@ export async function retryFailedTask( task: TaskListItem, projectDir: string, ): Promise { + if (task.kind !== 'failed') { + throw new Error(`retryFailedTask requires failed task. received: ${task.kind}`); + } + displayFailureInfo(task); - const pieceName = task.data?.piece ?? loadGlobalConfig().defaultPiece ?? 'default'; + const globalConfig = loadGlobalConfig(); + const pieceName = task.data?.piece ?? globalConfig.defaultPiece ?? 'default'; const pieceConfig = loadPieceByIdentifier(pieceName, projectDir); if (!pieceConfig) { @@ -77,32 +119,65 @@ export async function retryFailedTask( return false; } + const pieceDesc = getPieceDescription(pieceName, projectDir, globalConfig.interactivePreviewMovements); + const pieceContext: 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); + } + } + } + blankLine(); - const retryNote = await promptInput('Retry note (optional, press Enter to skip):'); - const trimmedNote = retryNote?.trim(); + const branchContext = buildRetryBranchContext(task); + const branchName = task.branch ?? task.name; + const instructResult = await runInstructMode( + projectDir, + branchContext, + branchName, + pieceContext, + runSessionContext, + ); + if (instructResult.action !== 'execute') { + return false; + } try { const runner = new TaskRunner(projectDir); const startMovement = selectedMovement !== pieceConfig.initialMovement ? selectedMovement : undefined; + const retryNote = appendRetryNote(task.data?.retry_note, instructResult.task); - runner.requeueFailedTask(task.name, startMovement, trimmedNote || undefined); + runner.requeueTask(task.name, ['failed'], startMovement, retryNote); success(`Task requeued: ${task.name}`); if (startMovement) { info(` Will start from: ${startMovement}`); } - if (trimmedNote) { - info(` Retry note: ${trimmedNote}`); - } + info(' Retry note: updated'); info(` File: ${task.filePath}`); log.info('Requeued failed task', { name: task.name, tasksFile: task.filePath, startMovement, - retryNote: trimmedNote, + retryNote, }); return true; diff --git a/src/infra/task/index.ts b/src/infra/task/index.ts index b511650..de1b865 100644 --- a/src/infra/task/index.ts +++ b/src/infra/task/index.ts @@ -58,3 +58,4 @@ export { stageAndCommit, getCurrentBranch } from './git.js'; export { autoCommitAndPush, type AutoCommitResult } from './autoCommit.js'; export { summarizeTaskName } from './summarize.js'; export { TaskWatcher, type TaskWatcherOptions } from './watcher.js'; +export { isStaleRunningTask } from './process.js'; diff --git a/src/infra/task/mapper.ts b/src/infra/task/mapper.ts index a7594f7..c424b75 100644 --- a/src/infra/task/mapper.ts +++ b/src/infra/task/mapper.ts @@ -121,6 +121,9 @@ function toBaseTaskListItem(projectDir: string, tasksFile: string, task: TaskRec content: firstLine(resolveTaskContent(projectDir, task)), branch: task.branch, worktreePath: task.worktree_path, + startedAt: task.started_at ?? undefined, + completedAt: task.completed_at ?? undefined, + ownerPid: task.owner_pid ?? undefined, data: toTaskData(projectDir, task), }; } diff --git a/src/infra/task/process.ts b/src/infra/task/process.ts new file mode 100644 index 0000000..6b04aad --- /dev/null +++ b/src/infra/task/process.ts @@ -0,0 +1,23 @@ +/** + * Shared process-level helpers. + */ + +export function isProcessAlive(ownerPid: number): boolean { + try { + process.kill(ownerPid, 0); + return true; + } catch (err) { + const nodeErr = err as NodeJS.ErrnoException; + if (nodeErr.code === 'ESRCH') { + return false; + } + if (nodeErr.code === 'EPERM') { + return true; + } + throw err; + } +} + +export function isStaleRunningTask(ownerPid: number | undefined): boolean { + return ownerPid == null || !isProcessAlive(ownerPid); +} diff --git a/src/infra/task/runner.ts b/src/infra/task/runner.ts index f1f0ef0..718d41c 100644 --- a/src/infra/task/runner.ts +++ b/src/infra/task/runner.ts @@ -1,5 +1,6 @@ import type { TaskFileData } from './schema.js'; import type { TaskInfo, TaskResult, TaskListItem } from './types.js'; +import type { TaskStatus } from './schema.js'; import { TaskStore } from './store.js'; import { TaskLifecycleService } from './taskLifecycleService.js'; import { TaskQueryService } from './taskQueryService.js'; @@ -73,6 +74,15 @@ export class TaskRunner { return this.lifecycle.requeueFailedTask(taskRef, startMovement, retryNote); } + requeueTask( + taskRef: string, + allowedStatuses: readonly TaskStatus[], + startMovement?: string, + retryNote?: string, + ): string { + return this.lifecycle.requeueTask(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 ea3a128..cd5ba2b 100644 --- a/src/infra/task/taskLifecycleService.ts +++ b/src/infra/task/taskLifecycleService.ts @@ -4,6 +4,8 @@ import type { TaskInfo, TaskResult } from './types.js'; import { toTaskInfo } from './mapper.js'; import { TaskStore } from './store.js'; import { firstLine, nowIso, sanitizeTaskName } from './naming.js'; +import { isStaleRunningTask } from './process.js'; +import type { TaskStatus } from './schema.js'; export class TaskLifecycleService { constructor( @@ -151,12 +153,25 @@ export class TaskLifecycleService { } requeueFailedTask(taskRef: string, startMovement?: string, retryNote?: string): string { + return this.requeueTask(taskRef, ['failed'], startMovement, retryNote); + } + + requeueTask( + taskRef: string, + allowedStatuses: readonly TaskStatus[], + startMovement?: string, + retryNote?: string, + ): string { const taskName = this.normalizeTaskRef(taskRef); this.store.update((current) => { - const index = current.tasks.findIndex((task) => task.name === taskName && task.status === 'failed'); + const index = current.tasks.findIndex((task) => ( + task.name === taskName + && allowedStatuses.includes(task.status) + )); if (index === -1) { - throw new Error(`Failed task not found: ${taskRef}`); + const expectedStatuses = allowedStatuses.join(', '); + throw new Error(`Task not found for requeue: ${taskRef} (expected status: ${expectedStatuses})`); } const target = current.tasks[index]!; @@ -197,26 +212,7 @@ export class TaskLifecycleService { } private isRunningTaskStale(task: TaskRecord): boolean { - if (task.owner_pid == null) { - return true; - } - return !this.isProcessAlive(task.owner_pid); - } - - private isProcessAlive(pid: number): boolean { - try { - process.kill(pid, 0); - return true; - } catch (err) { - const nodeErr = err as NodeJS.ErrnoException; - if (nodeErr.code === 'ESRCH') { - return false; - } - if (nodeErr.code === 'EPERM') { - return true; - } - throw err; - } + return isStaleRunningTask(task.owner_pid ?? undefined); } private generateTaskName(content: string, existingNames: string[]): string { diff --git a/src/infra/task/types.ts b/src/infra/task/types.ts index d8f3295..aee43fa 100644 --- a/src/infra/task/types.ts +++ b/src/infra/task/types.ts @@ -85,4 +85,7 @@ export interface TaskListItem { worktreePath?: string; data?: TaskFileData; failure?: TaskFailure; + startedAt?: string; + completedAt?: string; + ownerPid?: number; } diff --git a/src/shared/i18n/labels_en.yaml b/src/shared/i18n/labels_en.yaml index 607eafa..8e36598 100644 --- a/src/shared/i18n/labels_en.yaml +++ b/src/shared/i18n/labels_en.yaml @@ -35,6 +35,10 @@ interactive: quietDescription: "Generate instructions without asking questions" passthrough: "Passthrough" passthroughDescription: "Pass your input directly as task text" + runSelector: + confirm: "Reference a previous run's results?" + prompt: "Select a run to reference:" + noRuns: "No previous runs found." sessionSelector: confirm: "Choose a previous session?" prompt: "Resume from a recent session?" diff --git a/src/shared/i18n/labels_ja.yaml b/src/shared/i18n/labels_ja.yaml index eac4cd8..6f1d93b 100644 --- a/src/shared/i18n/labels_ja.yaml +++ b/src/shared/i18n/labels_ja.yaml @@ -35,6 +35,10 @@ interactive: quietDescription: "質問なしでベストエフォートの指示書を生成" passthrough: "パススルー" passthroughDescription: "入力をそのままタスクとして渡す" + runSelector: + confirm: "前回の実行結果を参照しますか?" + prompt: "参照するrunを選択してください:" + noRuns: "前回のrunが見つかりませんでした。" sessionSelector: confirm: "前回セッションを選択しますか?" prompt: "直近のセッションを引き継ぎますか?" diff --git a/src/shared/prompts/en/score_interactive_system_prompt.md b/src/shared/prompts/en/score_interactive_system_prompt.md index a7995d7..2372ced 100644 --- a/src/shared/prompts/en/score_interactive_system_prompt.md +++ b/src/shared/prompts/en/score_interactive_system_prompt.md @@ -1,7 +1,7 @@ # Interactive Mode Assistant @@ -43,3 +43,27 @@ The following agents will process the task sequentially. Understand each agent's - 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_summary_system_prompt.md b/src/shared/prompts/en/score_summary_system_prompt.md index c2f6aa1..a91a809 100644 --- a/src/shared/prompts/en/score_summary_system_prompt.md +++ b/src/shared/prompts/en/score_summary_system_prompt.md @@ -1,7 +1,7 @@ You are a task summarizer. Convert the conversation into a concrete task instruction for the planning step. @@ -31,3 +31,7 @@ Create the instruction in the format expected by this piece. {{conversation}} {{/if}} + +{{#if taskHistory}} +{{taskHistory}} +{{/if}} diff --git a/src/shared/prompts/ja/score_interactive_system_prompt.md b/src/shared/prompts/ja/score_interactive_system_prompt.md index 5b2169b..c47d267 100644 --- a/src/shared/prompts/ja/score_interactive_system_prompt.md +++ b/src/shared/prompts/ja/score_interactive_system_prompt.md @@ -1,7 +1,7 @@ # 対話モードアシスタント @@ -43,3 +43,27 @@ TAKTの対話モードを担当し、ユーザーと会話してピース実行 - エージェントが自力で解決できない情報(ユーザーの意図、優先度、制約条件など)を指示書に明確に含めてください - コードベースの調査、実装詳細の特定、依存関係の解析はエージェントに委ねてください {{/if}} +{{#if hasRunSession}} + +## 前回実行の参照 + +ユーザーが前回の実行結果を参照として選択しました。この情報を使って、何が起きたかを理解し、追加指示の作成を支援してください。 + +**タスク:** {{runTask}} +**ピース:** {{runPiece}} +**ステータス:** {{runStatus}} + +### ムーブメントログ + +{{runMovementLogs}} + +### レポート + +{{runReports}} + +### ガイダンス + +- 問題点や改善点を議論する際は、具体的なムーブメントの結果を参照してください +- 何がうまくいかなかったか、追加作業が必要な箇所をユーザーが特定できるよう支援してください +- 実行結果に基づいて、具体的なフォローアップ指示を提案してください +{{/if}} diff --git a/src/shared/prompts/ja/score_summary_system_prompt.md b/src/shared/prompts/ja/score_summary_system_prompt.md index bb1ec07..eb969e2 100644 --- a/src/shared/prompts/ja/score_summary_system_prompt.md +++ b/src/shared/prompts/ja/score_summary_system_prompt.md @@ -1,7 +1,7 @@ あなたはTAKTの対話モードを担当しています。これまでの会話内容を、ピース実行用の具体的なタスク指示書に変換してください。 @@ -38,3 +38,7 @@ {{conversation}} {{/if}} + +{{#if taskHistory}} +{{taskHistory}} +{{/if}}