interactiveモジュールの分割とタスク再キュー方式への移行

interactive.tsからsummary/runSelector/runSessionReader/selectorUtilsを分離し、
run session参照をrouting層からinstructMode層に移動。instructBranchで新タスク
作成の代わりに既存タスクのrequeueを使用する方式に変更。worktree確認プロンプトを
廃止し常時有効化。
This commit is contained in:
nrslib 2026-02-18 18:49:21 +09:00
parent 5f108b8cfd
commit 620e384251
41 changed files with 1981 additions and 427 deletions

View File

@ -94,6 +94,8 @@ takt hello
**注意:** `--task` オプションを指定すると対話モードをスキップして直接タスク実行されます。Issue 参照(`#6``--issue`)は対話モードの初期入力として使用されます。 **注意:** `--task` オプションを指定すると対話モードをスキップして直接タスク実行されます。Issue 参照(`#6``--issue`)は対話モードの初期入力として使用されます。
対話開始時には `takt list` の履歴を自動取得し、`failed` / `interrupted` / `completed` の実行結果を `pieceContext` に注入して会話要約へ反映します。要約では `Worktree ID``開始/終了時刻``最終結果``失敗要約``ログ参照キー` を参照できます。`takt list` の取得に失敗しても対話は継続されます。
**フロー:** **フロー:**
1. ピース選択 1. ピース選択
2. 対話モード選択assistant / persona / quiet / passthrough 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 takt list --non-interactive --format json
``` ```
対話モードでは、上記の実行履歴(`failed` / `interrupted` / `completed`)を起動時に再利用し、失敗事例や中断済み実行を再作業対象として特定しやすくします。
#### タスクディレクトリ運用(作成・実行・確認) #### タスクディレクトリ運用(作成・実行・確認)
1. `takt add` を実行して `.takt/tasks.yaml` に pending レコードが作られることを確認する。 1. `takt add` を実行して `.takt/tasks.yaml` に pending レコードが作られることを確認する。

View File

@ -114,6 +114,7 @@ describe('addTask', () => {
expect(task.task_dir).toBeTypeOf('string'); expect(task.task_dir).toBeTypeOf('string');
expect(readOrderContent(testDir, task.task_dir)).toContain('JWT認証を実装する'); expect(readOrderContent(testDir, task.task_dir)).toContain('JWT認証を実装する');
expect(task.piece).toBe('default'); expect(task.piece).toBe('default');
expect(task.worktree).toBe(true);
}); });
it('should include worktree settings when enabled', async () => { it('should include worktree settings when enabled', async () => {
@ -125,6 +126,7 @@ describe('addTask', () => {
const task = loadTasks(testDir).tasks[0]!; const task = loadTasks(testDir).tasks[0]!;
expect(task.worktree).toBe('/custom/path'); expect(task.worktree).toBe('/custom/path');
expect(task.branch).toBe('feat/branch'); expect(task.branch).toBe('feat/branch');
expect(task.auto_pr).toBe(true);
}); });
it('should create task from issue reference without interactive mode', async () => { it('should create task from issue reference without interactive mode', async () => {

View File

@ -56,6 +56,22 @@ vi.mock('../features/interactive/index.js', () => ({
quietMode: vi.fn(), quietMode: vi.fn(),
personaMode: vi.fn(), personaMode: vi.fn(),
resolveLanguage: vi.fn(() => 'en'), 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<string, (r: unknown) => 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', () => ({ vi.mock('../infra/config/index.js', () => ({
@ -110,6 +126,7 @@ const mockSelectRecentSession = vi.mocked(selectRecentSession);
const mockLoadGlobalConfig = vi.mocked(loadGlobalConfig); const mockLoadGlobalConfig = vi.mocked(loadGlobalConfig);
const mockConfirm = vi.mocked(confirm); const mockConfirm = vi.mocked(confirm);
const mockIsDirectTask = vi.mocked(isDirectTask); const mockIsDirectTask = vi.mocked(isDirectTask);
const mockTaskRunnerListAllTaskItems = vi.mocked(mockListAllTaskItems);
function createMockIssue(number: number): GitHubIssue { function createMockIssue(number: number): GitHubIssue {
return { return {
@ -133,6 +150,8 @@ beforeEach(() => {
mockConfirm.mockResolvedValue(true); mockConfirm.mockResolvedValue(true);
mockIsDirectTask.mockReturnValue(false); mockIsDirectTask.mockReturnValue(false);
mockParseIssueNumbers.mockReturnValue([]); mockParseIssueNumbers.mockReturnValue([]);
mockTaskRunnerListAllTaskItems.mockReturnValue([]);
mockIsStaleRunningTask.mockReturnValue(false);
}); });
describe('Issue resolution in routing', () => { 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', () => { describe('interactive mode cancel', () => {
it('should not call selectAndExecuteTask when interactive mode is cancelled', async () => { it('should not call selectAndExecuteTask when interactive mode is cancelled', async () => {
// Given // 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,
);
});
});
}); });

View File

@ -76,10 +76,12 @@ import { getProvider } from '../infra/providers/index.js';
import { runInstructMode } from '../features/tasks/list/instructMode.js'; import { runInstructMode } from '../features/tasks/list/instructMode.js';
import { selectOption } from '../shared/prompt/index.js'; import { selectOption } from '../shared/prompt/index.js';
import { info } from '../shared/ui/index.js'; import { info } from '../shared/ui/index.js';
import { loadTemplate } from '../shared/prompts/index.js';
const mockGetProvider = vi.mocked(getProvider); const mockGetProvider = vi.mocked(getProvider);
const mockSelectOption = vi.mocked(selectOption); const mockSelectOption = vi.mocked(selectOption);
const mockInfo = vi.mocked(info); const mockInfo = vi.mocked(info);
const mockLoadTemplate = vi.mocked(loadTemplate);
let savedIsTTY: boolean | undefined; let savedIsTTY: boolean | undefined;
let savedIsRaw: boolean | undefined; let savedIsRaw: boolean | undefined;
@ -279,4 +281,34 @@ describe('runInstructMode', () => {
expect(values).toContain('continue'); expect(values).toContain('continue');
expect(values).not.toContain('create_issue'); 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',
}),
);
});
}); });

View File

@ -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');
});
});

View File

@ -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<typeof getProvider>);
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<typeof getProvider>);
// 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 () => { it('should abort in-flight provider call on SIGINT during initial input', async () => {
mockGetProvider.mockReturnValue({ mockGetProvider.mockReturnValue({
setup: () => ({ setup: () => ({

View File

@ -58,6 +58,19 @@ describe('variable substitution', () => {
expect(result).toContain('You are the agent'); 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', () => { it('replaces multiple different variables', () => {
const result = loadTemplate('perform_judge_message', 'en', { const result = loadTemplate('perform_judge_message', 'en', {
agentOutput: 'test output', agentOutput: 'test output',

View File

@ -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 + '…'
});
});

View File

@ -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, unknown>,
): 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('');
});
});

View File

@ -105,8 +105,7 @@ describe('saveTaskFile', () => {
}); });
describe('saveTaskFromInteractive', () => { describe('saveTaskFromInteractive', () => {
it('should save task with worktree settings when user confirms', async () => { it('should always save task with worktree settings', async () => {
mockConfirm.mockResolvedValueOnce(true);
mockPromptInput.mockResolvedValueOnce(''); mockPromptInput.mockResolvedValueOnce('');
mockPromptInput.mockResolvedValueOnce(''); mockPromptInput.mockResolvedValueOnce('');
mockConfirm.mockResolvedValueOnce(true); mockConfirm.mockResolvedValueOnce(true);
@ -119,18 +118,22 @@ describe('saveTaskFromInteractive', () => {
expect(task.auto_pr).toBe(true); 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); mockConfirm.mockResolvedValueOnce(false);
await saveTaskFromInteractive(testDir, 'Task content'); await saveTaskFromInteractive(testDir, 'Task content');
const task = loadTasks(testDir).tasks[0]!; const task = loadTasks(testDir).tasks[0]!;
expect(task.worktree).toBeUndefined(); expect(task.worktree).toBe(true);
expect(task.branch).toBeUndefined(); expect(task.branch).toBeUndefined();
expect(task.auto_pr).toBeUndefined(); expect(task.auto_pr).toBe(false);
}); });
it('should display piece info when specified', async () => { it('should display piece info when specified', async () => {
mockPromptInput.mockResolvedValueOnce('');
mockPromptInput.mockResolvedValueOnce('');
mockConfirm.mockResolvedValueOnce(false); mockConfirm.mockResolvedValueOnce(false);
await saveTaskFromInteractive(testDir, 'Task content', 'review'); 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 () => { it('should record issue number in tasks.yaml when issue option is provided', async () => {
mockPromptInput.mockResolvedValueOnce('');
mockPromptInput.mockResolvedValueOnce('');
mockConfirm.mockResolvedValueOnce(false); mockConfirm.mockResolvedValueOnce(false);
await saveTaskFromInteractive(testDir, 'Fix login bug', 'default', { issue: 42 }); await saveTaskFromInteractive(testDir, 'Fix login bug', 'default', { issue: 42 });
@ -163,7 +168,6 @@ describe('saveTaskFromInteractive', () => {
mockConfirm.mockResolvedValueOnce(true); mockConfirm.mockResolvedValueOnce(true);
mockPromptInput.mockResolvedValueOnce(''); mockPromptInput.mockResolvedValueOnce('');
mockPromptInput.mockResolvedValueOnce(''); mockPromptInput.mockResolvedValueOnce('');
mockConfirm.mockResolvedValueOnce(true);
mockConfirm.mockResolvedValueOnce(false); mockConfirm.mockResolvedValueOnce(false);
await saveTaskFromInteractive(testDir, 'Task content', 'default', { 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(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]!; const task = loadTasks(testDir).tasks[0]!;
expect(task.issue).toBe(42); expect(task.issue).toBe(42);
expect(task.worktree).toBe(true); expect(task.worktree).toBe(true);

View File

@ -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');
});
});

View File

@ -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);
});
});

View File

@ -1,51 +1,40 @@
import { beforeEach, describe, expect, it, vi } from 'vitest'; import { beforeEach, describe, expect, it, vi } from 'vitest';
const { const {
mockAddTask, mockRequeueTask,
mockCompleteTask,
mockFailTask,
mockExecuteTask,
mockRunInstructMode, mockRunInstructMode,
mockDispatchConversationAction, mockDispatchConversationAction,
mockSelectPiece, mockSelectPiece,
mockConfirm,
mockGetLabel,
mockResolveLanguage,
mockListRecentRuns,
mockSelectRun,
mockLoadRunSessionContext,
} = vi.hoisted(() => ({ } = vi.hoisted(() => ({
mockAddTask: vi.fn(() => ({ mockRequeueTask: 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(),
mockRunInstructMode: vi.fn(), mockRunInstructMode: vi.fn(),
mockDispatchConversationAction: vi.fn(), mockDispatchConversationAction: vi.fn(),
mockSelectPiece: 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', () => ({ 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'), detectDefaultBranch: vi.fn(() => 'main'),
autoCommitAndPush: vi.fn(() => ({ success: false, message: 'no changes' })),
TaskRunner: class { TaskRunner: class {
addTask(...args: unknown[]) { requeueTask(...args: unknown[]) {
return mockAddTask(...args); return mockRequeueTask(...args);
}
completeTask(...args: unknown[]) {
return mockCompleteTask(...args);
}
failTask(...args: unknown[]) {
return mockFailTask(...args);
} }
}, },
})); }));
vi.mock('../infra/config/index.js', () => ({ vi.mock('../infra/config/index.js', () => ({
loadGlobalConfig: vi.fn(() => ({ interactivePreviewMovements: false })), loadGlobalConfig: vi.fn(() => ({ interactivePreviewMovements: 3, language: 'en' })),
getPieceDescription: vi.fn(() => ({ getPieceDescription: vi.fn(() => ({
name: 'default', name: 'default',
description: 'desc', 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', () => ({ vi.mock('../features/tasks/list/instructMode.js', () => ({
runInstructMode: (...args: unknown[]) => mockRunInstructMode(...args), runInstructMode: (...args: unknown[]) => mockRunInstructMode(...args),
})); }));
@ -74,6 +59,21 @@ vi.mock('../features/interactive/actionDispatcher.js', () => ({
dispatchConversationAction: (...args: unknown[]) => mockDispatchConversationAction(...args), 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', () => ({ vi.mock('../shared/ui/index.js', () => ({
info: vi.fn(), info: vi.fn(),
success: 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'; import { instructBranch } from '../features/tasks/list/taskActions.js';
describe('instructBranch execute flow', () => { describe('instructBranch requeue flow', () => {
beforeEach(() => { beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
mockSelectPiece.mockResolvedValue('default'); mockSelectPiece.mockResolvedValue('default');
mockRunInstructMode.mockResolvedValue({ type: 'execute', task: '追加して' }); mockRunInstructMode.mockResolvedValue({ action: 'execute', task: '追加指示A' });
mockDispatchConversationAction.mockImplementation(async (_result, handlers) => handlers.execute({ task: '追加して' })); 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 () => { it('should requeue the same completed task instead of creating another task', async () => {
mockExecuteTask.mockResolvedValue(true);
const result = await instructBranch('/project', { const result = await instructBranch('/project', {
kind: 'completed', kind: 'completed',
name: 'done-task', name: 'done-task',
@ -110,18 +113,20 @@ describe('instructBranch execute flow', () => {
content: 'done', content: 'done',
branch: 'takt/done-task', branch: 'takt/done-task',
worktreePath: '/project/.takt/worktrees/done-task', worktreePath: '/project/.takt/worktrees/done-task',
data: { task: 'done', retry_note: '既存ノート' },
}); });
expect(result).toBe(true); expect(result).toBe(true);
expect(mockAddTask).toHaveBeenCalledTimes(1); expect(mockRequeueTask).toHaveBeenCalledWith(
expect(mockCompleteTask).toHaveBeenCalledTimes(1); 'done-task',
expect(mockFailTask).not.toHaveBeenCalled(); ['completed', 'failed'],
undefined,
'既存ノート\n\n追加指示A',
);
}); });
it('should record addTask and failTask on failure', async () => { it('should set generated instruction as retry note when no existing note', async () => {
mockExecuteTask.mockResolvedValue(false); await instructBranch('/project', {
const result = await instructBranch('/project', {
kind: 'completed', kind: 'completed',
name: 'done-task', name: 'done-task',
createdAt: '2026-02-14T00:00:00.000Z', createdAt: '2026-02-14T00:00:00.000Z',
@ -129,18 +134,26 @@ describe('instructBranch execute flow', () => {
content: 'done', content: 'done',
branch: 'takt/done-task', branch: 'takt/done-task',
worktreePath: '/project/.takt/worktrees/done-task', worktreePath: '/project/.takt/worktrees/done-task',
data: { task: 'done' },
}); });
expect(result).toBe(false); expect(mockRequeueTask).toHaveBeenCalledWith(
expect(mockAddTask).toHaveBeenCalledTimes(1); 'done-task',
expect(mockFailTask).toHaveBeenCalledTimes(1); ['completed', 'failed'],
expect(mockCompleteTask).not.toHaveBeenCalled(); undefined,
'追加指示A',
);
}); });
it('should record failTask when executeTask throws', async () => { it('should load selected run context and pass it to instruct mode', async () => {
mockExecuteTask.mockRejectedValue(new Error('crashed')); 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', kind: 'completed',
name: 'done-task', name: 'done-task',
createdAt: '2026-02-14T00:00:00.000Z', createdAt: '2026-02-14T00:00:00.000Z',
@ -148,10 +161,18 @@ describe('instructBranch execute flow', () => {
content: 'done', content: 'done',
branch: 'takt/done-task', branch: 'takt/done-task',
worktreePath: '/project/.takt/worktrees/done-task', worktreePath: '/project/.takt/worktrees/done-task',
})).rejects.toThrow('crashed'); data: { task: 'done' },
});
expect(mockAddTask).toHaveBeenCalledTimes(1); expect(mockConfirm).toHaveBeenCalledWith("Reference a previous run's results?", false);
expect(mockFailTask).toHaveBeenCalledTimes(1); expect(mockSelectRun).toHaveBeenCalledWith('/project', 'en');
expect(mockCompleteTask).not.toHaveBeenCalled(); expect(mockLoadRunSessionContext).toHaveBeenCalledWith('/project', 'run-1');
expect(mockRunInstructMode).toHaveBeenCalledWith(
'/project',
expect.any(String),
'takt/done-task',
expect.anything(),
runContext,
);
}); });
}); });

View File

@ -6,7 +6,7 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
vi.mock('../shared/prompt/index.js', () => ({ vi.mock('../shared/prompt/index.js', () => ({
selectOption: vi.fn(), selectOption: vi.fn(),
promptInput: vi.fn(), confirm: vi.fn(),
})); }));
vi.mock('../shared/ui/index.js', () => ({ vi.mock('../shared/ui/index.js', () => ({
@ -29,21 +29,44 @@ vi.mock('../shared/utils/index.js', async (importOriginal) => ({
vi.mock('../infra/config/index.js', () => ({ vi.mock('../infra/config/index.js', () => ({
loadGlobalConfig: vi.fn(), loadGlobalConfig: vi.fn(),
loadPieceByIdentifier: 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 { success, error as logError } from '../shared/ui/index.js';
import { loadGlobalConfig, loadPieceByIdentifier } from '../infra/config/index.js'; import { loadGlobalConfig, loadPieceByIdentifier } from '../infra/config/index.js';
import { retryFailedTask } from '../features/tasks/list/taskRetryActions.js'; import { retryFailedTask } from '../features/tasks/list/taskRetryActions.js';
import type { TaskListItem } from '../infra/task/types.js'; import type { TaskListItem } from '../infra/task/types.js';
import type { PieceConfig } from '../core/models/index.js'; import type { PieceConfig } from '../core/models/index.js';
import { runInstructMode } from '../features/tasks/list/instructMode.js';
const mockSelectOption = vi.mocked(selectOption); const mockSelectOption = vi.mocked(selectOption);
const mockPromptInput = vi.mocked(promptInput); const mockConfirm = vi.mocked(confirm);
const mockSuccess = vi.mocked(success); const mockSuccess = vi.mocked(success);
const mockLogError = vi.mocked(logError); const mockLogError = vi.mocked(logError);
const mockLoadGlobalConfig = vi.mocked(loadGlobalConfig); const mockLoadGlobalConfig = vi.mocked(loadGlobalConfig);
const mockLoadPieceByIdentifier = vi.mocked(loadPieceByIdentifier); const mockLoadPieceByIdentifier = vi.mocked(loadPieceByIdentifier);
const mockRunInstructMode = vi.mocked(runInstructMode);
let tmpDir: string; let tmpDir: string;
@ -107,7 +130,8 @@ describe('retryFailedTask', () => {
mockLoadGlobalConfig.mockReturnValue({ defaultPiece: 'default' }); mockLoadGlobalConfig.mockReturnValue({ defaultPiece: 'default' });
mockLoadPieceByIdentifier.mockReturnValue(defaultPieceConfig); mockLoadPieceByIdentifier.mockReturnValue(defaultPieceConfig);
mockSelectOption.mockResolvedValue('implement'); mockSelectOption.mockResolvedValue('implement');
mockPromptInput.mockResolvedValue(''); mockConfirm.mockResolvedValue(false);
mockRunInstructMode.mockResolvedValue({ action: 'execute', task: '追加指示A' });
const result = await retryFailedTask(task, tmpDir); const result = await retryFailedTask(task, tmpDir);
@ -117,6 +141,7 @@ describe('retryFailedTask', () => {
const tasksYaml = fs.readFileSync(path.join(tmpDir, '.takt', 'tasks.yaml'), 'utf-8'); const tasksYaml = fs.readFileSync(path.join(tmpDir, '.takt', 'tasks.yaml'), 'utf-8');
expect(tasksYaml).toContain('status: pending'); expect(tasksYaml).toContain('status: pending');
expect(tasksYaml).toContain('start_movement: implement'); expect(tasksYaml).toContain('start_movement: implement');
expect(tasksYaml).toContain('retry_note: 追加指示A');
}); });
it('should not add start_movement when initial movement is selected', async () => { it('should not add start_movement when initial movement is selected', async () => {
@ -125,13 +150,34 @@ describe('retryFailedTask', () => {
mockLoadGlobalConfig.mockReturnValue({ defaultPiece: 'default' }); mockLoadGlobalConfig.mockReturnValue({ defaultPiece: 'default' });
mockLoadPieceByIdentifier.mockReturnValue(defaultPieceConfig); mockLoadPieceByIdentifier.mockReturnValue(defaultPieceConfig);
mockSelectOption.mockResolvedValue('plan'); mockSelectOption.mockResolvedValue('plan');
mockPromptInput.mockResolvedValue(''); mockConfirm.mockResolvedValue(false);
mockRunInstructMode.mockResolvedValue({ action: 'execute', task: '追加指示A' });
const result = await retryFailedTask(task, tmpDir); const result = await retryFailedTask(task, tmpDir);
expect(result).toBe(true); expect(result).toBe(true);
const tasksYaml = fs.readFileSync(path.join(tmpDir, '.takt', 'tasks.yaml'), 'utf-8'); const tasksYaml = fs.readFileSync(path.join(tmpDir, '.takt', 'tasks.yaml'), 'utf-8');
expect(tasksYaml).not.toContain('start_movement'); 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 () => { it('should return false and show error when piece not found', async () => {

View File

@ -5,7 +5,7 @@
* pipeline mode, or interactive mode. * 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 { confirm } from '../../shared/prompt/index.js';
import { getErrorMessage } from '../../shared/utils/index.js'; import { getErrorMessage } from '../../shared/utils/index.js';
import { getLabel } from '../../shared/i18n/index.js'; import { getLabel } from '../../shared/i18n/index.js';
@ -20,13 +20,14 @@ import {
quietMode, quietMode,
personaMode, personaMode,
resolveLanguage, resolveLanguage,
dispatchConversationAction,
type InteractiveModeResult, type InteractiveModeResult,
} from '../../features/interactive/index.js'; } from '../../features/interactive/index.js';
import { dispatchConversationAction } from '../../features/interactive/actionDispatcher.js';
import { getPieceDescription, loadGlobalConfig } from '../../infra/config/index.js'; import { getPieceDescription, loadGlobalConfig } from '../../infra/config/index.js';
import { DEFAULT_PIECE_NAME } from '../../shared/constants.js'; import { DEFAULT_PIECE_NAME } from '../../shared/constants.js';
import { program, resolvedCwd, pipelineMode } from './program.js'; import { program, resolvedCwd, pipelineMode } from './program.js';
import { resolveAgentOverrides, parseCreateWorktreeOption, isDirectTask } from './helpers.js'; import { resolveAgentOverrides, parseCreateWorktreeOption, isDirectTask } from './helpers.js';
import { loadTaskHistory } from './taskHistory.js';
/** /**
* Resolve issue references from CLI input. * Resolve issue references from CLI input.
@ -131,7 +132,7 @@ export async function executeDefaultAction(task?: string): Promise<void> {
initialInput = issueResult.initialInput; initialInput = issueResult.initialInput;
} }
} catch (e) { } catch (e) {
error(getErrorMessage(e)); logError(getErrorMessage(e));
process.exit(1); process.exit(1);
} }
@ -160,6 +161,7 @@ export async function executeDefaultAction(task?: string): Promise<void> {
description: pieceDesc.description, description: pieceDesc.description,
pieceStructure: pieceDesc.pieceStructure, pieceStructure: pieceDesc.pieceStructure,
movementPreviews: pieceDesc.movementPreviews, movementPreviews: pieceDesc.movementPreviews,
taskHistory: loadTaskHistory(resolvedCwd, lang),
}; };
let result: InteractiveModeResult; let result: InteractiveModeResult;

View File

@ -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 [];
}
}

View File

@ -9,7 +9,9 @@ export {
selectPostSummaryAction, selectPostSummaryAction,
formatMovementPreviews, formatMovementPreviews,
formatSessionStatus, formatSessionStatus,
normalizeTaskHistorySummary,
type PieceContext, type PieceContext,
type TaskHistorySummaryItem,
type InteractiveModeResult, type InteractiveModeResult,
type InteractiveModeAction, type InteractiveModeAction,
} from './interactive.js'; } from './interactive.js';
@ -19,3 +21,6 @@ export { selectRecentSession } from './sessionSelector.js';
export { passthroughMode } from './passthroughMode.js'; export { passthroughMode } from './passthroughMode.js';
export { quietMode } from './quietMode.js'; export { quietMode } from './quietMode.js';
export { personaMode } from './personaMode.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';

View File

@ -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<SummaryActionValue>();
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<PostSummaryAction | null> {
blankLine();
info(proposedLabel);
console.log(task);
return selectOption<PostSummaryAction>(actionPrompt, options);
}
export function selectPostSummaryAction(
task: string,
proposedLabel: string,
ui: InteractiveSummaryUIText,
): Promise<PostSummaryAction | null> {
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'],
),
);
}

View File

@ -13,17 +13,20 @@
import type { Language } from '../../core/models/index.js'; import type { Language } from '../../core/models/index.js';
import { import {
type SessionState, type SessionState,
type MovementPreview,
} from '../../infra/config/index.js'; } 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 { getLabel, getLabelObject } from '../../shared/i18n/index.js';
import { loadTemplate } from '../../shared/prompts/index.js';
import { import {
initializeSession, initializeSession,
displayAndClearSessionState, displayAndClearSessionState,
runConversationLoop, runConversationLoop,
} from './conversationLoop.js'; } 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 */ /** Shape of interactive UI text */
export interface InteractiveUIText { export interface InteractiveUIText {
@ -57,7 +60,7 @@ export function formatSessionStatus(state: SessionState, lang: 'en' | 'ja'): str
lines.push( lines.push(
getLabel('interactive.previousTask.error', lang, { getLabel('interactive.previousTask.error', lang, {
error: state.errorMessage!, error: state.errorMessage!,
}) }),
); );
} else if (state.status === 'user_stopped') { } else if (state.status === 'user_stopped') {
lines.push(getLabel('interactive.previousTask.userStopped', lang)); lines.push(getLabel('interactive.previousTask.userStopped', lang));
@ -67,7 +70,7 @@ export function formatSessionStatus(state: SessionState, lang: 'en' | 'ja'): str
lines.push( lines.push(
getLabel('interactive.previousTask.piece', lang, { getLabel('interactive.previousTask.piece', lang, {
pieceName: state.pieceName, pieceName: state.pieceName,
}) }),
); );
// Timestamp // Timestamp
@ -75,7 +78,7 @@ export function formatSessionStatus(state: SessionState, lang: 'en' | 'ja'): str
lines.push( lines.push(
getLabel('interactive.previousTask.timestamp', lang, { getLabel('interactive.previousTask.timestamp', lang, {
timestamp, timestamp,
}) }),
); );
return lines.join('\n'); return lines.join('\n');
@ -85,197 +88,19 @@ export function resolveLanguage(lang?: Language): 'en' | 'ja' {
return lang === 'ja' ? 'ja' : 'en'; return lang === 'ja' ? 'ja' : 'en';
} }
/** /** Default toolset for interactive mode */
* Format MovementPreview[] into a Markdown string for template injection. export const DEFAULT_INTERACTIVE_TOOLS = ['Read', 'Glob', 'Grep', 'Bash', 'WebSearch', 'WebFetch'];
* 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');
}
/** /**
* Build the summary prompt (used as both system prompt and user message). * 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( export {
history: ConversationMessage[], buildSummaryPrompt,
hasSession: boolean, formatMovementPreviews,
lang: 'en' | 'ja', type ConversationMessage,
noTranscriptNote: string, type PieceContext,
conversationLabel: string, type TaskHistorySummaryItem,
pieceContext?: PieceContext, } from './interactive-summary.js';
): 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<SummaryActionValue>();
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<PostSummaryAction | null> {
blankLine();
info(proposedLabel);
console.log(task);
return selectOption<PostSummaryAction>(actionPrompt, options);
}
export async function selectPostSummaryAction(
task: string,
proposedLabel: string,
ui: InteractiveUIText,
): Promise<PostSummaryAction | null> {
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'];
/** /**
* Run the interactive task input mode. * Run the interactive task input mode.
@ -291,6 +116,7 @@ export async function interactiveMode(
initialInput?: string, initialInput?: string,
pieceContext?: PieceContext, pieceContext?: PieceContext,
sessionId?: string, sessionId?: string,
runSessionContext?: RunSessionContext,
): Promise<InteractiveModeResult> { ): Promise<InteractiveModeResult> {
const baseCtx = initializeSession(cwd, 'interactive'); const baseCtx = initializeSession(cwd, 'interactive');
const ctx = sessionId ? { ...baseCtx, sessionId } : baseCtx; const ctx = sessionId ? { ...baseCtx, sessionId } : baseCtx;
@ -298,10 +124,17 @@ export async function interactiveMode(
displayAndClearSessionState(cwd, ctx.lang); displayAndClearSessionState(cwd, ctx.lang);
const hasPreview = !!pieceContext?.movementPreviews?.length; 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, { const systemPrompt = loadTemplate('score_interactive_system_prompt', ctx.lang, {
hasPiecePreview: hasPreview, hasPiecePreview: hasPreview,
pieceStructure: pieceContext?.pieceStructure ?? '', pieceStructure: pieceContext?.pieceStructure ?? '',
movementDetails: hasPreview ? formatMovementPreviews(pieceContext!.movementPreviews!, ctx.lang) : '', movementDetails: hasPreview ? formatMovementPreviews(pieceContext!.movementPreviews!, ctx.lang) : '',
hasRunSession,
...runPromptVars,
}); });
const policyContent = loadTemplate('score_interactive_policy', ctx.lang, {}); const policyContent = loadTemplate('score_interactive_policy', ctx.lang, {});
const ui = getLabelObject<InteractiveUIText>('interactive.ui', ctx.lang); const ui = getLabelObject<InteractiveUIText>('interactive.ui', ctx.lang);
@ -327,3 +160,25 @@ export async function interactiveMode(
introMessage: ui.intro, introMessage: ui.intro,
}, pieceContext, initialInput); }, 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;
}

View File

@ -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<string | null> {
const runs = listRecentRuns(cwd);
if (runs.length === 0) {
info(getLabel('interactive.runSelector.noRuns', lang));
return null;
}
const options: SelectOptionItem<string>[] = 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<string>(prompt, options);
return selected;
}

View File

@ -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'),
};
}

View File

@ -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',
});
}

View File

@ -9,6 +9,7 @@ import { loadSessionIndex, extractLastAssistantResponse } from '../../infra/clau
import { selectOption, type SelectOptionItem } from '../../shared/prompt/index.js'; import { selectOption, type SelectOptionItem } from '../../shared/prompt/index.js';
import { getLabel } from '../../shared/i18n/index.js'; import { getLabel } from '../../shared/i18n/index.js';
import { info } from '../../shared/ui/index.js'; import { info } from '../../shared/ui/index.js';
import { truncateForLabel, formatDateForSelector } from './selectorUtils.js';
/** Maximum number of sessions to display */ /** Maximum number of sessions to display */
const MAX_DISPLAY_SESSIONS = 10; const MAX_DISPLAY_SESSIONS = 10;
@ -16,30 +17,6 @@ const MAX_DISPLAY_SESSIONS = 10;
/** Maximum length for last response preview */ /** Maximum length for last response preview */
const MAX_RESPONSE_PREVIEW_LENGTH = 200; 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. * Prompt user to select from recent Claude Code sessions.
* *
@ -70,7 +47,7 @@ export async function selectRecentSession(
for (const session of displaySessions) { for (const session of displaySessions) {
const label = truncateForLabel(session.firstPrompt, 60); 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, { const messagesStr = getLabel('interactive.sessionSelector.messages', lang, {
count: String(session.messageCount), count: String(session.messageCount),
}); });

View File

@ -125,11 +125,6 @@ export async function createIssueAndSaveTask(cwd: string, task: string, piece?:
} }
async function promptWorktreeSettings(): Promise<WorktreeSettings> { async function promptWorktreeSettings(): Promise<WorktreeSettings> {
const useWorktree = await confirm('Create worktree?', true);
if (!useWorktree) {
return {};
}
const customPath = await promptInput('Worktree path (Enter for auto)'); const customPath = await promptInput('Worktree path (Enter for auto)');
const worktree: boolean | string = customPath || true; const worktree: boolean | string = customPath || true;

View File

@ -19,6 +19,7 @@ import {
selectSummaryAction, selectSummaryAction,
type PieceContext, type PieceContext,
} from '../../interactive/interactive.js'; } from '../../interactive/interactive.js';
import { type RunSessionContext, formatRunSessionForPrompt } from '../../interactive/runSessionReader.js';
import { loadTemplate } from '../../../shared/prompts/index.js'; import { loadTemplate } from '../../../shared/prompts/index.js';
import { getLabelObject } from '../../../shared/i18n/index.js'; import { getLabelObject } from '../../../shared/i18n/index.js';
import { loadGlobalConfig } from '../../../infra/config/index.js'; import { loadGlobalConfig } from '../../../infra/config/index.js';
@ -68,6 +69,7 @@ export async function runInstructMode(
branchContext: string, branchContext: string,
branchName: string, branchName: string,
pieceContext?: PieceContext, pieceContext?: PieceContext,
runSessionContext?: RunSessionContext,
): Promise<InstructModeResult> { ): Promise<InstructModeResult> {
const globalConfig = loadGlobalConfig(); const globalConfig = loadGlobalConfig();
const lang = resolveLanguage(globalConfig.language); const lang = resolveLanguage(globalConfig.language);
@ -83,10 +85,17 @@ export async function runInstructMode(
const ui = getLabelObject<InstructUIText>('instruct.ui', ctx.lang); const ui = getLabelObject<InstructUIText>('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, { const systemPrompt = loadTemplate('score_interactive_system_prompt', ctx.lang, {
hasPiecePreview: false, hasPiecePreview: false,
pieceStructure: '', pieceStructure: '',
movementDetails: '', movementDetails: '',
hasRunSession,
...runPromptVars,
}); });
const branchIntro = ctx.lang === 'ja' const branchIntro = ctx.lang === 'ja'

View File

@ -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<RunSessionContext | undefined> {
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);
}

View File

@ -65,7 +65,7 @@ export async function showDiffAndPromptActionForTask(
`Action for ${branch}:`, `Action for ${branch}:`,
[ [
{ label: 'View diff', value: 'diff', description: 'Show full diff in pager' }, { 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: 'Try merge', value: 'try', description: 'Squash merge (stage changes without commit)' },
{ label: 'Merge & cleanup', value: 'merge', description: 'Merge and delete branch' }, { label: 'Merge & cleanup', value: 'merge', description: 'Merge and delete branch' },
{ label: 'Delete', value: 'delete', description: 'Discard changes, delete branch' }, { label: 'Delete', value: 'delete', description: 'Discard changes, delete branch' },

View File

@ -1,23 +1,19 @@
import { execFileSync } from 'node:child_process'; import { execFileSync } from 'node:child_process';
import { import {
createTempCloneForBranch,
removeClone,
removeCloneMeta,
TaskRunner, TaskRunner,
} from '../../../infra/task/index.js'; } from '../../../infra/task/index.js';
import { loadGlobalConfig, getPieceDescription } from '../../../infra/config/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 { createLogger, getErrorMessage } from '../../../shared/utils/index.js';
import { executeTask } from '../execute/taskExecution.js';
import type { TaskExecutionOptions } from '../execute/types.js'; import type { TaskExecutionOptions } from '../execute/types.js';
import { buildBooleanTaskResult, persistTaskError, persistTaskResult } from '../execute/taskResultHandler.js';
import { runInstructMode } from './instructMode.js'; import { runInstructMode } from './instructMode.js';
import { saveTaskFile } from '../add/index.js';
import { selectPiece } from '../../pieceSelection/index.js'; import { selectPiece } from '../../pieceSelection/index.js';
import { dispatchConversationAction } from '../../interactive/actionDispatcher.js'; import { dispatchConversationAction } from '../../interactive/actionDispatcher.js';
import type { PieceContext } from '../../interactive/interactive.js'; import type { PieceContext } from '../../interactive/interactive.js';
import { type BranchActionTarget, resolveTargetBranch, resolveTargetWorktreePath } from './taskActionTarget.js'; import { resolveLanguage } from '../../interactive/index.js';
import { detectDefaultBranch, autoCommitAndPush } from '../../../infra/task/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'); const log = createLogger('list-tasks');
@ -70,10 +66,13 @@ function getBranchContext(projectDir: string, branch: string): string {
export async function instructBranch( export async function instructBranch(
projectDir: string, projectDir: string,
target: BranchActionTarget, target: BranchActionTarget,
options?: TaskExecutionOptions, _options?: TaskExecutionOptions,
): Promise<boolean> { ): Promise<boolean> {
if (!('kind' in target)) {
throw new Error('Instruct requeue requires a task target.');
}
const branch = resolveTargetBranch(target); const branch = resolveTargetBranch(target);
const worktreePath = resolveTargetWorktreePath(target);
const selectedPiece = await selectPiece(projectDir); const selectedPiece = await selectPiece(projectDir);
if (!selectedPiece) { if (!selectedPiece) {
@ -90,96 +89,32 @@ export async function instructBranch(
movementPreviews: pieceDesc.movementPreviews, movementPreviews: pieceDesc.movementPreviews,
}; };
const lang = resolveLanguage(globalConfig.language);
const runSessionContext = await selectRunSessionContext(projectDir, lang);
const branchContext = getBranchContext(projectDir, branch); 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<boolean> => {
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, { return dispatchConversationAction(result, {
cancel: () => { cancel: () => {
info('Cancelled'); info('Cancelled');
return false; return false;
}, },
save_task: async ({ task }) => { execute: async ({ task }) => requeueWithInstruction(task),
const created = await saveTaskFile(projectDir, task, { save_task: async ({ task }) => requeueWithInstruction(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);
}
},
}); });
} }

View File

@ -7,11 +7,16 @@
import type { TaskListItem } from '../../../infra/task/index.js'; import type { TaskListItem } from '../../../infra/task/index.js';
import { TaskRunner } from '../../../infra/task/index.js'; import { TaskRunner } from '../../../infra/task/index.js';
import { loadPieceByIdentifier, loadGlobalConfig } from '../../../infra/config/index.js'; import { loadPieceByIdentifier, loadGlobalConfig, getPieceDescription } from '../../../infra/config/index.js';
import { selectOption, promptInput } from '../../../shared/prompt/index.js'; import { selectOption } from '../../../shared/prompt/index.js';
import { success, error as logError, info, header, blankLine, status } from '../../../shared/ui/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 { createLogger, getErrorMessage } from '../../../shared/utils/index.js';
import type { PieceConfig } from '../../../core/models/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'); const log = createLogger('list-tasks');
@ -53,6 +58,38 @@ async function selectStartMovement(
return await selectOption<string>('Start from movement:', options); return await selectOption<string>('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. * Retry a failed task.
* *
@ -62,9 +99,14 @@ export async function retryFailedTask(
task: TaskListItem, task: TaskListItem,
projectDir: string, projectDir: string,
): Promise<boolean> { ): Promise<boolean> {
if (task.kind !== 'failed') {
throw new Error(`retryFailedTask requires failed task. received: ${task.kind}`);
}
displayFailureInfo(task); 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); const pieceConfig = loadPieceByIdentifier(pieceName, projectDir);
if (!pieceConfig) { if (!pieceConfig) {
@ -77,32 +119,65 @@ export async function retryFailedTask(
return false; 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(); blankLine();
const retryNote = await promptInput('Retry note (optional, press Enter to skip):'); const branchContext = buildRetryBranchContext(task);
const trimmedNote = retryNote?.trim(); const branchName = task.branch ?? task.name;
const instructResult = await runInstructMode(
projectDir,
branchContext,
branchName,
pieceContext,
runSessionContext,
);
if (instructResult.action !== 'execute') {
return false;
}
try { try {
const runner = new TaskRunner(projectDir); const runner = new TaskRunner(projectDir);
const startMovement = selectedMovement !== pieceConfig.initialMovement const startMovement = selectedMovement !== pieceConfig.initialMovement
? selectedMovement ? selectedMovement
: undefined; : 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}`); success(`Task requeued: ${task.name}`);
if (startMovement) { if (startMovement) {
info(` Will start from: ${startMovement}`); info(` Will start from: ${startMovement}`);
} }
if (trimmedNote) { info(' Retry note: updated');
info(` Retry note: ${trimmedNote}`);
}
info(` File: ${task.filePath}`); info(` File: ${task.filePath}`);
log.info('Requeued failed task', { log.info('Requeued failed task', {
name: task.name, name: task.name,
tasksFile: task.filePath, tasksFile: task.filePath,
startMovement, startMovement,
retryNote: trimmedNote, retryNote,
}); });
return true; return true;

View File

@ -58,3 +58,4 @@ export { stageAndCommit, getCurrentBranch } from './git.js';
export { autoCommitAndPush, type AutoCommitResult } from './autoCommit.js'; export { autoCommitAndPush, type AutoCommitResult } from './autoCommit.js';
export { summarizeTaskName } from './summarize.js'; export { summarizeTaskName } from './summarize.js';
export { TaskWatcher, type TaskWatcherOptions } from './watcher.js'; export { TaskWatcher, type TaskWatcherOptions } from './watcher.js';
export { isStaleRunningTask } from './process.js';

View File

@ -121,6 +121,9 @@ function toBaseTaskListItem(projectDir: string, tasksFile: string, task: TaskRec
content: firstLine(resolveTaskContent(projectDir, task)), content: firstLine(resolveTaskContent(projectDir, task)),
branch: task.branch, branch: task.branch,
worktreePath: task.worktree_path, worktreePath: task.worktree_path,
startedAt: task.started_at ?? undefined,
completedAt: task.completed_at ?? undefined,
ownerPid: task.owner_pid ?? undefined,
data: toTaskData(projectDir, task), data: toTaskData(projectDir, task),
}; };
} }

23
src/infra/task/process.ts Normal file
View File

@ -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);
}

View File

@ -1,5 +1,6 @@
import type { TaskFileData } from './schema.js'; import type { TaskFileData } from './schema.js';
import type { TaskInfo, TaskResult, TaskListItem } from './types.js'; import type { TaskInfo, TaskResult, TaskListItem } from './types.js';
import type { TaskStatus } from './schema.js';
import { TaskStore } from './store.js'; import { TaskStore } from './store.js';
import { TaskLifecycleService } from './taskLifecycleService.js'; import { TaskLifecycleService } from './taskLifecycleService.js';
import { TaskQueryService } from './taskQueryService.js'; import { TaskQueryService } from './taskQueryService.js';
@ -73,6 +74,15 @@ export class TaskRunner {
return this.lifecycle.requeueFailedTask(taskRef, startMovement, retryNote); 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 { deletePendingTask(name: string): void {
this.deletion.deletePendingTask(name); this.deletion.deletePendingTask(name);
} }

View File

@ -4,6 +4,8 @@ import type { TaskInfo, TaskResult } from './types.js';
import { toTaskInfo } from './mapper.js'; import { toTaskInfo } from './mapper.js';
import { TaskStore } from './store.js'; import { TaskStore } from './store.js';
import { firstLine, nowIso, sanitizeTaskName } from './naming.js'; import { firstLine, nowIso, sanitizeTaskName } from './naming.js';
import { isStaleRunningTask } from './process.js';
import type { TaskStatus } from './schema.js';
export class TaskLifecycleService { export class TaskLifecycleService {
constructor( constructor(
@ -151,12 +153,25 @@ export class TaskLifecycleService {
} }
requeueFailedTask(taskRef: string, startMovement?: string, retryNote?: string): string { 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); const taskName = this.normalizeTaskRef(taskRef);
this.store.update((current) => { 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) { 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]!; const target = current.tasks[index]!;
@ -197,26 +212,7 @@ export class TaskLifecycleService {
} }
private isRunningTaskStale(task: TaskRecord): boolean { private isRunningTaskStale(task: TaskRecord): boolean {
if (task.owner_pid == null) { return isStaleRunningTask(task.owner_pid ?? undefined);
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;
}
} }
private generateTaskName(content: string, existingNames: string[]): string { private generateTaskName(content: string, existingNames: string[]): string {

View File

@ -85,4 +85,7 @@ export interface TaskListItem {
worktreePath?: string; worktreePath?: string;
data?: TaskFileData; data?: TaskFileData;
failure?: TaskFailure; failure?: TaskFailure;
startedAt?: string;
completedAt?: string;
ownerPid?: number;
} }

View File

@ -35,6 +35,10 @@ interactive:
quietDescription: "Generate instructions without asking questions" quietDescription: "Generate instructions without asking questions"
passthrough: "Passthrough" passthrough: "Passthrough"
passthroughDescription: "Pass your input directly as task text" 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: sessionSelector:
confirm: "Choose a previous session?" confirm: "Choose a previous session?"
prompt: "Resume from a recent session?" prompt: "Resume from a recent session?"

View File

@ -35,6 +35,10 @@ interactive:
quietDescription: "質問なしでベストエフォートの指示書を生成" quietDescription: "質問なしでベストエフォートの指示書を生成"
passthrough: "パススルー" passthrough: "パススルー"
passthroughDescription: "入力をそのままタスクとして渡す" passthroughDescription: "入力をそのままタスクとして渡す"
runSelector:
confirm: "前回の実行結果を参照しますか?"
prompt: "参照するrunを選択してください:"
noRuns: "前回のrunが見つかりませんでした。"
sessionSelector: sessionSelector:
confirm: "前回セッションを選択しますか?" confirm: "前回セッションを選択しますか?"
prompt: "直近のセッションを引き継ぎますか?" prompt: "直近のセッションを引き継ぎますか?"

View File

@ -1,7 +1,7 @@
<!-- <!--
template: score_interactive_system_prompt template: score_interactive_system_prompt
role: system prompt for interactive planning mode role: system prompt for interactive planning mode
vars: hasPiecePreview, pieceStructure, movementDetails vars: hasPiecePreview, pieceStructure, movementDetails, hasRunSession, runTask, runPiece, runStatus, runMovementLogs, runReports
caller: features/interactive caller: features/interactive
--> -->
# Interactive Mode Assistant # 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.) - 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 - Delegate codebase investigation, implementation details, and dependency analysis to the agents
{{/if}} {{/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}}

View File

@ -1,7 +1,7 @@
<!-- <!--
template: score_summary_system_prompt template: score_summary_system_prompt
role: system prompt for conversation-to-task summarization role: system prompt for conversation-to-task summarization
vars: pieceInfo, pieceName, pieceDescription, movementDetails, conversation vars: pieceInfo, pieceName, pieceDescription, movementDetails, taskHistory, conversation
caller: features/interactive caller: features/interactive
--> -->
You are a task summarizer. Convert the conversation into a concrete task instruction for the planning step. 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}} {{conversation}}
{{/if}} {{/if}}
{{#if taskHistory}}
{{taskHistory}}
{{/if}}

View File

@ -1,7 +1,7 @@
<!-- <!--
template: score_interactive_system_prompt template: score_interactive_system_prompt
role: system prompt for interactive planning mode role: system prompt for interactive planning mode
vars: hasPiecePreview, pieceStructure, movementDetails vars: hasPiecePreview, pieceStructure, movementDetails, hasRunSession, runTask, runPiece, runStatus, runMovementLogs, runReports
caller: features/interactive caller: features/interactive
--> -->
# 対話モードアシスタント # 対話モードアシスタント
@ -43,3 +43,27 @@ TAKTの対話モードを担当し、ユーザーと会話してピース実行
- エージェントが自力で解決できない情報(ユーザーの意図、優先度、制約条件など)を指示書に明確に含めてください - エージェントが自力で解決できない情報(ユーザーの意図、優先度、制約条件など)を指示書に明確に含めてください
- コードベースの調査、実装詳細の特定、依存関係の解析はエージェントに委ねてください - コードベースの調査、実装詳細の特定、依存関係の解析はエージェントに委ねてください
{{/if}} {{/if}}
{{#if hasRunSession}}
## 前回実行の参照
ユーザーが前回の実行結果を参照として選択しました。この情報を使って、何が起きたかを理解し、追加指示の作成を支援してください。
**タスク:** {{runTask}}
**ピース:** {{runPiece}}
**ステータス:** {{runStatus}}
### ムーブメントログ
{{runMovementLogs}}
### レポート
{{runReports}}
### ガイダンス
- 問題点や改善点を議論する際は、具体的なムーブメントの結果を参照してください
- 何がうまくいかなかったか、追加作業が必要な箇所をユーザーが特定できるよう支援してください
- 実行結果に基づいて、具体的なフォローアップ指示を提案してください
{{/if}}

View File

@ -1,7 +1,7 @@
<!-- <!--
template: score_summary_system_prompt template: score_summary_system_prompt
role: system prompt for conversation-to-task summarization role: system prompt for conversation-to-task summarization
vars: pieceInfo, pieceName, pieceDescription, movementDetails, conversation vars: pieceInfo, pieceName, pieceDescription, movementDetails, taskHistory, conversation
caller: features/interactive caller: features/interactive
--> -->
あなたはTAKTの対話モードを担当しています。これまでの会話内容を、ピース実行用の具体的なタスク指示書に変換してください。 あなたはTAKTの対話モードを担当しています。これまでの会話内容を、ピース実行用の具体的なタスク指示書に変換してください。
@ -38,3 +38,7 @@
{{conversation}} {{conversation}}
{{/if}} {{/if}}
{{#if taskHistory}}
{{taskHistory}}
{{/if}}