interactiveモジュールの分割とタスク再キュー方式への移行
interactive.tsからsummary/runSelector/runSessionReader/selectorUtilsを分離し、 run session参照をrouting層からinstructMode層に移動。instructBranchで新タスク 作成の代わりに既存タスクのrequeueを使用する方式に変更。worktree確認プロンプトを 廃止し常時有効化。
This commit is contained in:
parent
5f108b8cfd
commit
620e384251
@ -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 レコードが作られることを確認する。
|
||||||
|
|||||||
@ -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 () => {
|
||||||
|
|||||||
@ -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,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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',
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
102
src/__tests__/interactive-summary.test.ts
Normal file
102
src/__tests__/interactive-summary.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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: () => ({
|
||||||
|
|||||||
@ -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',
|
||||||
|
|||||||
91
src/__tests__/runSelector.test.ts
Normal file
91
src/__tests__/runSelector.test.ts
Normal 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 + '…'
|
||||||
|
});
|
||||||
|
});
|
||||||
297
src/__tests__/runSessionReader.test.ts
Normal file
297
src/__tests__/runSessionReader.test.ts
Normal 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('');
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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);
|
||||||
|
|||||||
60
src/__tests__/selectorUtils.test.ts
Normal file
60
src/__tests__/selectorUtils.test.ts
Normal 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');
|
||||||
|
});
|
||||||
|
});
|
||||||
58
src/__tests__/task-process-alive.test.ts
Normal file
58
src/__tests__/task-process-alive.test.ts
Normal 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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -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,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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 () => {
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
55
src/app/cli/taskHistory.ts
Normal file
55
src/app/cli/taskHistory.ts
Normal 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 [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ -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';
|
||||||
|
|||||||
263
src/features/interactive/interactive-summary.ts
Normal file
263
src/features/interactive/interactive-summary.ts
Normal 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'],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
|||||||
49
src/features/interactive/runSelector.ts
Normal file
49
src/features/interactive/runSelector.ts
Normal 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;
|
||||||
|
}
|
||||||
205
src/features/interactive/runSessionReader.ts
Normal file
205
src/features/interactive/runSessionReader.ts
Normal 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'),
|
||||||
|
};
|
||||||
|
}
|
||||||
27
src/features/interactive/selectorUtils.ts
Normal file
27
src/features/interactive/selectorUtils.ts
Normal 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',
|
||||||
|
});
|
||||||
|
}
|
||||||
@ -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),
|
||||||
});
|
});
|
||||||
|
|||||||
@ -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;
|
||||||
|
|
||||||
|
|||||||
@ -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'
|
||||||
|
|||||||
43
src/features/tasks/list/requeueHelpers.ts
Normal file
43
src/features/tasks/list/requeueHelpers.ts
Normal 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);
|
||||||
|
}
|
||||||
@ -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' },
|
||||||
|
|||||||
@ -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);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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;
|
||||||
|
|||||||
@ -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';
|
||||||
|
|||||||
@ -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
23
src/infra/task/process.ts
Normal 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);
|
||||||
|
}
|
||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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?"
|
||||||
|
|||||||
@ -35,6 +35,10 @@ interactive:
|
|||||||
quietDescription: "質問なしでベストエフォートの指示書を生成"
|
quietDescription: "質問なしでベストエフォートの指示書を生成"
|
||||||
passthrough: "パススルー"
|
passthrough: "パススルー"
|
||||||
passthroughDescription: "入力をそのままタスクとして渡す"
|
passthroughDescription: "入力をそのままタスクとして渡す"
|
||||||
|
runSelector:
|
||||||
|
confirm: "前回の実行結果を参照しますか?"
|
||||||
|
prompt: "参照するrunを選択してください:"
|
||||||
|
noRuns: "前回のrunが見つかりませんでした。"
|
||||||
sessionSelector:
|
sessionSelector:
|
||||||
confirm: "前回セッションを選択しますか?"
|
confirm: "前回セッションを選択しますか?"
|
||||||
prompt: "直近のセッションを引き継ぎますか?"
|
prompt: "直近のセッションを引き継ぎますか?"
|
||||||
|
|||||||
@ -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}}
|
||||||
|
|||||||
@ -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}}
|
||||||
|
|||||||
@ -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}}
|
||||||
|
|||||||
@ -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}}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user