resolved 失敗タスクの再投入とムーブメント開始位置の選択機能 #110

This commit is contained in:
nrslib 2026-02-06 17:22:22 +09:00
parent 163561a5b3
commit 919215fad3
21 changed files with 1216 additions and 15 deletions

View File

@ -15,6 +15,7 @@ description: TAKT ピースエンジン。Agent Team を使ったマルチエー
- **自分で作業するな** — コーディング、レビュー、設計、テスト等は全てチームメイトに委任する
- **タスクを自分で分析して1つの Task にまとめるな** — movement を1つずつ順番に実行せよ
- **movement をスキップするな** — 必ず initial_movement から開始し、Rule 評価で決まった次の movement に進む
- **"yolo" をピース名と誤解するな** — "yolo" は YOLOYou Only Live Onceの俗語で「無謀・適当・いい加減」という意味。「yolo ではレビューして」= 「適当にやらずにちゃんとレビューして」という意味であり、ピース作成の指示ではない
### あなたの仕事は4つだけ
@ -23,6 +24,8 @@ description: TAKT ピースエンジン。Agent Team を使ったマルチエー
3. **Task tool** でチームメイトを起動して作業を委任する
4. チームメイトの出力から Rule 評価を行い、次の movement を決定する
**重要**: ユーザーが明示的に指示するまで git commit を実行してはならない。実装完了 ≠ コミット許可。
### ツールの使い分け(重要)
| やること | 使うツール | 説明 |

View File

@ -622,5 +622,93 @@ describe('PieceEngine Integration: Happy Path', () => {
new PieceEngine(config, tmpDir, 'test task', { projectCwd: tmpDir });
}).toThrow('nonexistent_step');
});
it('should throw when startMovement option references nonexistent movement', () => {
const config = buildDefaultPieceConfig();
expect(() => {
new PieceEngine(config, tmpDir, 'test task', {
projectCwd: tmpDir,
startMovement: 'nonexistent',
});
}).toThrow('Unknown movement: nonexistent');
});
});
// =====================================================
// 9. startMovement option
// =====================================================
describe('startMovement option', () => {
it('should start from specified movement instead of initialMovement', async () => {
const config = buildDefaultPieceConfig();
// Start from ai_review, skipping plan and implement
engine = new PieceEngine(config, tmpDir, 'test task', {
projectCwd: tmpDir,
startMovement: 'ai_review',
});
mockRunAgentSequence([
makeResponse({ agent: 'ai_review', content: 'No issues' }),
makeResponse({ agent: 'arch-review', content: 'Architecture OK' }),
makeResponse({ agent: 'security-review', content: 'Security OK' }),
makeResponse({ agent: 'supervise', content: 'All passed' }),
]);
mockDetectMatchedRuleSequence([
{ index: 0, method: 'phase1_tag' }, // ai_review → reviewers
{ index: 0, method: 'phase1_tag' }, // arch-review → approved
{ index: 0, method: 'phase1_tag' }, // security-review → approved
{ index: 0, method: 'aggregate' }, // reviewers(all approved) → supervise
{ index: 0, method: 'phase1_tag' }, // supervise → COMPLETE
]);
const startFn = vi.fn();
engine.on('movement:start', startFn);
const state = await engine.run();
expect(state.status).toBe('completed');
// Should only run 3 movements: ai_review, reviewers, supervise
expect(state.iteration).toBe(3);
// First movement should be ai_review, not plan
const startedMovements = startFn.mock.calls.map(call => (call[0] as PieceMovement).name);
expect(startedMovements[0]).toBe('ai_review');
expect(startedMovements).not.toContain('plan');
expect(startedMovements).not.toContain('implement');
});
it('should use initialMovement when startMovement is not specified', async () => {
const config = buildDefaultPieceConfig();
engine = new PieceEngine(config, tmpDir, 'test task', { projectCwd: tmpDir });
mockRunAgentSequence([
makeResponse({ agent: 'plan', content: 'Plan complete' }),
makeResponse({ agent: 'implement', content: 'Implementation done' }),
makeResponse({ agent: 'ai_review', content: 'No issues' }),
makeResponse({ agent: 'arch-review', content: 'Architecture OK' }),
makeResponse({ agent: 'security-review', content: 'Security OK' }),
makeResponse({ agent: 'supervise', content: 'All passed' }),
]);
mockDetectMatchedRuleSequence([
{ index: 0, method: 'phase1_tag' },
{ index: 0, method: 'phase1_tag' },
{ index: 0, method: 'phase1_tag' },
{ index: 0, method: 'phase1_tag' },
{ index: 0, method: 'phase1_tag' },
{ index: 0, method: 'aggregate' },
{ index: 0, method: 'phase1_tag' },
]);
const startFn = vi.fn();
engine.on('movement:start', startFn);
await engine.run();
// First movement should be plan (the initialMovement)
const startedMovements = startFn.mock.calls.map(call => (call[0] as PieceMovement).name);
expect(startedMovements[0]).toBe('plan');
});
});
});

View File

@ -13,6 +13,7 @@ import {
appendNdjsonLine,
loadNdjsonLog,
loadSessionLog,
extractFailureInfo,
type LatestLogPointer,
type SessionLog,
type NdjsonRecord,
@ -638,4 +639,131 @@ describe('NDJSON log', () => {
expect(log!.history).toHaveLength(0);
});
});
describe('extractFailureInfo', () => {
it('should return null for non-existent file', () => {
const result = extractFailureInfo('/nonexistent/path.jsonl');
expect(result).toBeNull();
});
it('should extract failure info from aborted piece log', () => {
const filepath = initNdjsonLog('20260205-120000-abc123', 'failing task', 'wf', projectDir);
// Add step_start for plan
appendNdjsonLine(filepath, {
type: 'step_start',
step: 'plan',
agent: 'planner',
iteration: 1,
timestamp: '2025-01-01T00:00:01.000Z',
});
// Add step_complete for plan
appendNdjsonLine(filepath, {
type: 'step_complete',
step: 'plan',
agent: 'planner',
status: 'done',
content: 'Plan done',
instruction: 'Plan it',
timestamp: '2025-01-01T00:00:02.000Z',
} satisfies NdjsonStepComplete);
// Add step_start for implement (fails before completing)
appendNdjsonLine(filepath, {
type: 'step_start',
step: 'implement',
agent: 'coder',
iteration: 2,
timestamp: '2025-01-01T00:00:03.000Z',
});
// Add piece_abort
appendNdjsonLine(filepath, {
type: 'piece_abort',
iterations: 1,
reason: 'spawn node ENOENT',
endTime: '2025-01-01T00:00:04.000Z',
} satisfies NdjsonPieceAbort);
const result = extractFailureInfo(filepath);
expect(result).not.toBeNull();
expect(result!.lastCompletedMovement).toBe('plan');
expect(result!.failedMovement).toBe('implement');
expect(result!.iterations).toBe(1);
expect(result!.errorMessage).toBe('spawn node ENOENT');
expect(result!.sessionId).toBe('20260205-120000-abc123');
});
it('should handle log with only completed movements (no abort)', () => {
const filepath = initNdjsonLog('sess-success-001', 'task', 'wf', projectDir);
appendNdjsonLine(filepath, {
type: 'step_start',
step: 'plan',
agent: 'planner',
iteration: 1,
timestamp: '2025-01-01T00:00:01.000Z',
});
appendNdjsonLine(filepath, {
type: 'step_complete',
step: 'plan',
agent: 'planner',
status: 'done',
content: 'Plan done',
instruction: 'Plan it',
timestamp: '2025-01-01T00:00:02.000Z',
} satisfies NdjsonStepComplete);
appendNdjsonLine(filepath, {
type: 'piece_complete',
iterations: 1,
endTime: '2025-01-01T00:00:03.000Z',
});
const result = extractFailureInfo(filepath);
expect(result).not.toBeNull();
expect(result!.lastCompletedMovement).toBe('plan');
expect(result!.failedMovement).toBeNull();
expect(result!.iterations).toBe(1);
expect(result!.errorMessage).toBeNull();
});
it('should handle log with no step_complete records', () => {
const filepath = initNdjsonLog('sess-fail-early-001', 'task', 'wf', projectDir);
appendNdjsonLine(filepath, {
type: 'step_start',
step: 'plan',
agent: 'planner',
iteration: 1,
timestamp: '2025-01-01T00:00:01.000Z',
});
appendNdjsonLine(filepath, {
type: 'piece_abort',
iterations: 0,
reason: 'API error',
endTime: '2025-01-01T00:00:02.000Z',
} satisfies NdjsonPieceAbort);
const result = extractFailureInfo(filepath);
expect(result).not.toBeNull();
expect(result!.lastCompletedMovement).toBeNull();
expect(result!.failedMovement).toBe('plan');
expect(result!.iterations).toBe(0);
expect(result!.errorMessage).toBe('API error');
});
it('should return null for empty file', () => {
const logsDir = join(projectDir, '.takt', 'logs');
mkdirSync(logsDir, { recursive: true });
const filepath = join(logsDir, 'empty.jsonl');
writeFileSync(filepath, '', 'utf-8');
const result = extractFailureInfo(filepath);
expect(result).toBeNull();
});
});
});

View File

@ -273,4 +273,149 @@ describe('TaskRunner', () => {
expect(runner.getTasksDir()).toBe(join(testDir, '.takt', 'tasks'));
});
});
describe('requeueFailedTask', () => {
it('should copy task file from failed to tasks directory', () => {
runner.ensureDirs();
// Create a failed task directory
const failedDir = join(testDir, '.takt', 'failed', '2026-01-31T12-00-00_my-task');
mkdirSync(failedDir, { recursive: true });
writeFileSync(join(failedDir, 'my-task.yaml'), 'task: Do something\n');
writeFileSync(join(failedDir, 'report.md'), '# Report');
writeFileSync(join(failedDir, 'log.json'), '{}');
const result = runner.requeueFailedTask(failedDir);
// Task file should be copied to tasks dir
expect(existsSync(result)).toBe(true);
expect(result).toBe(join(testDir, '.takt', 'tasks', 'my-task.yaml'));
// Original failed directory should still exist
expect(existsSync(failedDir)).toBe(true);
// Task content should be preserved
const content = readFileSync(result, 'utf-8');
expect(content).toBe('task: Do something\n');
});
it('should add start_movement to YAML task file when specified', () => {
runner.ensureDirs();
const failedDir = join(testDir, '.takt', 'failed', '2026-01-31T12-00-00_retry-task');
mkdirSync(failedDir, { recursive: true });
writeFileSync(join(failedDir, 'retry-task.yaml'), 'task: Retry me\npiece: default\n');
const result = runner.requeueFailedTask(failedDir, 'implement');
const content = readFileSync(result, 'utf-8');
expect(content).toContain('start_movement: implement');
expect(content).toContain('task: Retry me');
expect(content).toContain('piece: default');
});
it('should replace existing start_movement in YAML task file', () => {
runner.ensureDirs();
const failedDir = join(testDir, '.takt', 'failed', '2026-01-31T12-00-00_replace-task');
mkdirSync(failedDir, { recursive: true });
writeFileSync(join(failedDir, 'replace-task.yaml'), 'task: Replace me\nstart_movement: plan\n');
const result = runner.requeueFailedTask(failedDir, 'ai_review');
const content = readFileSync(result, 'utf-8');
expect(content).toContain('start_movement: ai_review');
expect(content).not.toContain('start_movement: plan');
});
it('should not modify markdown task files even with startMovement', () => {
runner.ensureDirs();
const failedDir = join(testDir, '.takt', 'failed', '2026-01-31T12-00-00_md-task');
mkdirSync(failedDir, { recursive: true });
writeFileSync(join(failedDir, 'md-task.md'), '# Task\nDo something');
const result = runner.requeueFailedTask(failedDir, 'implement');
const content = readFileSync(result, 'utf-8');
// Markdown files should not have start_movement added
expect(content).toBe('# Task\nDo something');
expect(content).not.toContain('start_movement');
});
it('should throw error when no task file found', () => {
runner.ensureDirs();
const failedDir = join(testDir, '.takt', 'failed', '2026-01-31T12-00-00_no-task');
mkdirSync(failedDir, { recursive: true });
writeFileSync(join(failedDir, 'report.md'), '# Report');
expect(() => runner.requeueFailedTask(failedDir)).toThrow(
/No task file found in failed directory/
);
});
it('should throw error when failed directory does not exist', () => {
runner.ensureDirs();
expect(() => runner.requeueFailedTask('/nonexistent/path')).toThrow(
/Failed to read failed task directory/
);
});
it('should add retry_note to YAML task file when specified', () => {
runner.ensureDirs();
const failedDir = join(testDir, '.takt', 'failed', '2026-01-31T12-00-00_note-task');
mkdirSync(failedDir, { recursive: true });
writeFileSync(join(failedDir, 'note-task.yaml'), 'task: Task with note\n');
const result = runner.requeueFailedTask(failedDir, undefined, 'Fixed the ENOENT error');
const content = readFileSync(result, 'utf-8');
expect(content).toContain('retry_note: "Fixed the ENOENT error"');
expect(content).toContain('task: Task with note');
});
it('should escape double quotes in retry_note', () => {
runner.ensureDirs();
const failedDir = join(testDir, '.takt', 'failed', '2026-01-31T12-00-00_quote-task');
mkdirSync(failedDir, { recursive: true });
writeFileSync(join(failedDir, 'quote-task.yaml'), 'task: Task with quotes\n');
const result = runner.requeueFailedTask(failedDir, undefined, 'Fixed "spawn node ENOENT" error');
const content = readFileSync(result, 'utf-8');
expect(content).toContain('retry_note: "Fixed \\"spawn node ENOENT\\" error"');
});
it('should add both start_movement and retry_note when both specified', () => {
runner.ensureDirs();
const failedDir = join(testDir, '.takt', 'failed', '2026-01-31T12-00-00_both-task');
mkdirSync(failedDir, { recursive: true });
writeFileSync(join(failedDir, 'both-task.yaml'), 'task: Task with both\n');
const result = runner.requeueFailedTask(failedDir, 'implement', 'Retrying from implement');
const content = readFileSync(result, 'utf-8');
expect(content).toContain('start_movement: implement');
expect(content).toContain('retry_note: "Retrying from implement"');
});
it('should not add retry_note to markdown task files', () => {
runner.ensureDirs();
const failedDir = join(testDir, '.takt', 'failed', '2026-01-31T12-00-00_md-note-task');
mkdirSync(failedDir, { recursive: true });
writeFileSync(join(failedDir, 'md-note-task.md'), '# Task\nDo something');
const result = runner.requeueFailedTask(failedDir, undefined, 'Should be ignored');
const content = readFileSync(result, 'utf-8');
expect(content).toBe('# Task\nDo something');
expect(content).not.toContain('retry_note');
});
});
});

View File

@ -0,0 +1,351 @@
/**
* Tests for taskRetryActions failed task retry functionality
*/
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
vi.mock('../shared/prompt/index.js', () => ({
selectOption: vi.fn(),
promptInput: vi.fn(),
}));
vi.mock('../shared/ui/index.js', () => ({
success: vi.fn(),
error: vi.fn(),
info: vi.fn(),
header: vi.fn(),
blankLine: vi.fn(),
status: vi.fn(),
}));
vi.mock('../shared/utils/index.js', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
createLogger: () => ({
info: vi.fn(),
error: vi.fn(),
}),
}));
vi.mock('../infra/fs/session.js', () => ({
extractFailureInfo: vi.fn(),
}));
vi.mock('../infra/config/index.js', () => ({
loadGlobalConfig: vi.fn(),
loadPieceByIdentifier: vi.fn(),
}));
import { selectOption, promptInput } from '../shared/prompt/index.js';
import { success, error as logError } from '../shared/ui/index.js';
import { loadGlobalConfig, loadPieceByIdentifier } from '../infra/config/index.js';
import { retryFailedTask } from '../features/tasks/list/taskRetryActions.js';
import type { TaskListItem } from '../infra/task/types.js';
import type { PieceConfig } from '../core/models/index.js';
const mockSelectOption = vi.mocked(selectOption);
const mockPromptInput = vi.mocked(promptInput);
const mockSuccess = vi.mocked(success);
const mockLogError = vi.mocked(logError);
const mockLoadGlobalConfig = vi.mocked(loadGlobalConfig);
const mockLoadPieceByIdentifier = vi.mocked(loadPieceByIdentifier);
let tmpDir: string;
const defaultPieceConfig: PieceConfig = {
name: 'default',
description: 'Default piece',
initialMovement: 'plan',
maxIterations: 30,
movements: [
{ name: 'plan', agent: 'planner', instruction: '' },
{ name: 'implement', agent: 'coder', instruction: '' },
{ name: 'review', agent: 'reviewer', instruction: '' },
],
};
const customPieceConfig: PieceConfig = {
name: 'custom',
description: 'Custom piece',
initialMovement: 'step1',
maxIterations: 10,
movements: [
{ name: 'step1', agent: 'coder', instruction: '' },
{ name: 'step2', agent: 'reviewer', instruction: '' },
],
};
beforeEach(() => {
vi.clearAllMocks();
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'takt-test-retry-'));
});
afterEach(() => {
fs.rmSync(tmpDir, { recursive: true, force: true });
});
describe('retryFailedTask', () => {
it('should requeue task with selected movement', async () => {
// Given: a failed task directory with a task file
const failedDir = path.join(tmpDir, '.takt', 'failed', '2025-01-15T12-34-56_my-task');
const tasksDir = path.join(tmpDir, '.takt', 'tasks');
fs.mkdirSync(failedDir, { recursive: true });
fs.mkdirSync(tasksDir, { recursive: true });
fs.writeFileSync(path.join(failedDir, 'my-task.yaml'), 'task: Do something\n');
const task: TaskListItem = {
kind: 'failed',
name: 'my-task',
createdAt: '2025-01-15T12:34:56',
filePath: failedDir,
content: 'Do something',
};
mockLoadGlobalConfig.mockReturnValue({ defaultPiece: 'default' });
mockLoadPieceByIdentifier.mockReturnValue(defaultPieceConfig);
mockSelectOption.mockResolvedValue('implement');
mockPromptInput.mockResolvedValue(''); // Empty retry note
// When
const result = await retryFailedTask(task, tmpDir);
// Then
expect(result).toBe(true);
expect(mockSuccess).toHaveBeenCalledWith('Task requeued: my-task');
// Verify requeued file
const requeuedFile = path.join(tasksDir, 'my-task.yaml');
expect(fs.existsSync(requeuedFile)).toBe(true);
const content = fs.readFileSync(requeuedFile, 'utf-8');
expect(content).toContain('start_movement: implement');
});
it('should use piece field from task file instead of defaultPiece', async () => {
// Given: a failed task with piece: custom in YAML
const failedDir = path.join(tmpDir, '.takt', 'failed', '2025-01-15T12-34-56_custom-task');
const tasksDir = path.join(tmpDir, '.takt', 'tasks');
fs.mkdirSync(failedDir, { recursive: true });
fs.mkdirSync(tasksDir, { recursive: true });
fs.writeFileSync(
path.join(failedDir, 'custom-task.yaml'),
'task: Do something\npiece: custom\n',
);
const task: TaskListItem = {
kind: 'failed',
name: 'custom-task',
createdAt: '2025-01-15T12:34:56',
filePath: failedDir,
content: 'Do something',
};
mockLoadGlobalConfig.mockReturnValue({ defaultPiece: 'default' });
// Should be called with 'custom', not 'default'
mockLoadPieceByIdentifier.mockImplementation((name: string) => {
if (name === 'custom') return customPieceConfig;
if (name === 'default') return defaultPieceConfig;
return null;
});
mockSelectOption.mockResolvedValue('step2');
mockPromptInput.mockResolvedValue('');
// When
const result = await retryFailedTask(task, tmpDir);
// Then
expect(result).toBe(true);
expect(mockLoadPieceByIdentifier).toHaveBeenCalledWith('custom', tmpDir);
expect(mockSuccess).toHaveBeenCalledWith('Task requeued: custom-task');
});
it('should return false when user cancels movement selection', async () => {
// Given
const failedDir = path.join(tmpDir, '.takt', 'failed', '2025-01-15T12-34-56_my-task');
fs.mkdirSync(failedDir, { recursive: true });
fs.writeFileSync(path.join(failedDir, 'my-task.yaml'), 'task: Do something\n');
const task: TaskListItem = {
kind: 'failed',
name: 'my-task',
createdAt: '2025-01-15T12:34:56',
filePath: failedDir,
content: 'Do something',
};
mockLoadGlobalConfig.mockReturnValue({ defaultPiece: 'default' });
mockLoadPieceByIdentifier.mockReturnValue(defaultPieceConfig);
mockSelectOption.mockResolvedValue(null); // User cancelled
// No need to mock promptInput since user cancelled before reaching it
// When
const result = await retryFailedTask(task, tmpDir);
// Then
expect(result).toBe(false);
expect(mockSuccess).not.toHaveBeenCalled();
expect(mockPromptInput).not.toHaveBeenCalled();
});
it('should return false and show error when piece not found', async () => {
// Given
const failedDir = path.join(tmpDir, '.takt', 'failed', '2025-01-15T12-34-56_my-task');
fs.mkdirSync(failedDir, { recursive: true });
fs.writeFileSync(path.join(failedDir, 'my-task.yaml'), 'task: Do something\n');
const task: TaskListItem = {
kind: 'failed',
name: 'my-task',
createdAt: '2025-01-15T12:34:56',
filePath: failedDir,
content: 'Do something',
};
mockLoadGlobalConfig.mockReturnValue({ defaultPiece: 'nonexistent' });
mockLoadPieceByIdentifier.mockReturnValue(null);
// When
const result = await retryFailedTask(task, tmpDir);
// Then
expect(result).toBe(false);
expect(mockLogError).toHaveBeenCalledWith(
'Piece "nonexistent" not found. Cannot determine available movements.',
);
});
it('should fallback to defaultPiece when task file has no piece field', async () => {
// Given: a failed task without piece field in YAML
const failedDir = path.join(tmpDir, '.takt', 'failed', '2025-01-15T12-34-56_plain-task');
const tasksDir = path.join(tmpDir, '.takt', 'tasks');
fs.mkdirSync(failedDir, { recursive: true });
fs.mkdirSync(tasksDir, { recursive: true });
fs.writeFileSync(
path.join(failedDir, 'plain-task.yaml'),
'task: Do something without piece\n',
);
const task: TaskListItem = {
kind: 'failed',
name: 'plain-task',
createdAt: '2025-01-15T12:34:56',
filePath: failedDir,
content: 'Do something without piece',
};
mockLoadGlobalConfig.mockReturnValue({ defaultPiece: 'default' });
mockLoadPieceByIdentifier.mockImplementation((name: string) => {
if (name === 'default') return defaultPieceConfig;
return null;
});
mockSelectOption.mockResolvedValue('plan');
mockPromptInput.mockResolvedValue('');
// When
const result = await retryFailedTask(task, tmpDir);
// Then
expect(result).toBe(true);
expect(mockLoadPieceByIdentifier).toHaveBeenCalledWith('default', tmpDir);
});
it('should not add start_movement when initial movement is selected', async () => {
// Given
const failedDir = path.join(tmpDir, '.takt', 'failed', '2025-01-15T12-34-56_my-task');
const tasksDir = path.join(tmpDir, '.takt', 'tasks');
fs.mkdirSync(failedDir, { recursive: true });
fs.mkdirSync(tasksDir, { recursive: true });
fs.writeFileSync(path.join(failedDir, 'my-task.yaml'), 'task: Do something\n');
const task: TaskListItem = {
kind: 'failed',
name: 'my-task',
createdAt: '2025-01-15T12:34:56',
filePath: failedDir,
content: 'Do something',
};
mockLoadGlobalConfig.mockReturnValue({ defaultPiece: 'default' });
mockLoadPieceByIdentifier.mockReturnValue(defaultPieceConfig);
mockSelectOption.mockResolvedValue('plan'); // Initial movement
mockPromptInput.mockResolvedValue(''); // Empty retry note
// When
const result = await retryFailedTask(task, tmpDir);
// Then
expect(result).toBe(true);
// Verify requeued file does not have start_movement
const requeuedFile = path.join(tasksDir, 'my-task.yaml');
const content = fs.readFileSync(requeuedFile, 'utf-8');
expect(content).not.toContain('start_movement');
});
it('should add retry_note when user provides one', async () => {
// Given
const failedDir = path.join(tmpDir, '.takt', 'failed', '2025-01-15T12-34-56_retry-note-task');
const tasksDir = path.join(tmpDir, '.takt', 'tasks');
fs.mkdirSync(failedDir, { recursive: true });
fs.mkdirSync(tasksDir, { recursive: true });
fs.writeFileSync(path.join(failedDir, 'retry-note-task.yaml'), 'task: Do something\n');
const task: TaskListItem = {
kind: 'failed',
name: 'retry-note-task',
createdAt: '2025-01-15T12:34:56',
filePath: failedDir,
content: 'Do something',
};
mockLoadGlobalConfig.mockReturnValue({ defaultPiece: 'default' });
mockLoadPieceByIdentifier.mockReturnValue(defaultPieceConfig);
mockSelectOption.mockResolvedValue('implement');
mockPromptInput.mockResolvedValue('Fixed spawn node ENOENT error');
// When
const result = await retryFailedTask(task, tmpDir);
// Then
expect(result).toBe(true);
const requeuedFile = path.join(tasksDir, 'retry-note-task.yaml');
const content = fs.readFileSync(requeuedFile, 'utf-8');
expect(content).toContain('start_movement: implement');
expect(content).toContain('retry_note: "Fixed spawn node ENOENT error"');
});
it('should not add retry_note when user skips it', async () => {
// Given
const failedDir = path.join(tmpDir, '.takt', 'failed', '2025-01-15T12-34-56_no-note-task');
const tasksDir = path.join(tmpDir, '.takt', 'tasks');
fs.mkdirSync(failedDir, { recursive: true });
fs.mkdirSync(tasksDir, { recursive: true });
fs.writeFileSync(path.join(failedDir, 'no-note-task.yaml'), 'task: Do something\n');
const task: TaskListItem = {
kind: 'failed',
name: 'no-note-task',
createdAt: '2025-01-15T12:34:56',
filePath: failedDir,
content: 'Do something',
};
mockLoadGlobalConfig.mockReturnValue({ defaultPiece: 'default' });
mockLoadPieceByIdentifier.mockReturnValue(defaultPieceConfig);
mockSelectOption.mockResolvedValue('implement');
mockPromptInput.mockResolvedValue(''); // Empty string - user skipped
// When
const result = await retryFailedTask(task, tmpDir);
// Then
expect(result).toBe(true);
const requeuedFile = path.join(tasksDir, 'no-note-task.yaml');
const content = fs.readFileSync(requeuedFile, 'utf-8');
expect(content).toContain('start_movement: implement');
expect(content).not.toContain('retry_note');
});
});

View File

@ -35,6 +35,7 @@ export interface MovementExecutorDeps {
readonly getPieceMovements: () => ReadonlyArray<{ name: string; description?: string }>;
readonly getPieceName: () => string;
readonly getPieceDescription: () => string | undefined;
readonly getRetryNote: () => string | undefined;
readonly detectRuleIndex: (content: string, movementName: string) => number;
readonly callAiJudge: (
agentOutput: string,
@ -75,6 +76,7 @@ export class MovementExecutor {
currentMovementIndex: pieceMovements.findIndex(s => s.name === step.name),
pieceName: this.deps.getPieceName(),
pieceDescription: this.deps.getPieceDescription(),
retryNote: this.deps.getRetryNote(),
}).build();
}

View File

@ -110,6 +110,7 @@ export class PieceEngine extends EventEmitter {
getPieceMovements: () => this.config.movements.map(s => ({ name: s.name, description: s.description })),
getPieceName: () => this.getPieceName(),
getPieceDescription: () => this.getPieceDescription(),
getRetryNote: () => this.options.retryNote,
detectRuleIndex: this.detectRuleIndex,
callAiJudge: this.callAiJudge,
onPhaseStart: (step, phase, phaseName, instruction) => {
@ -160,6 +161,14 @@ export class PieceEngine extends EventEmitter {
throw new Error(ERROR_MESSAGES.UNKNOWN_MOVEMENT(this.config.initialMovement));
}
// Validate startMovement option if specified
if (this.options.startMovement) {
const startMovement = this.config.movements.find((s) => s.name === this.options.startMovement);
if (!startMovement) {
throw new Error(ERROR_MESSAGES.UNKNOWN_MOVEMENT(this.options.startMovement));
}
}
const movementNames = new Set(this.config.movements.map((s) => s.name));
movementNames.add(COMPLETE_MOVEMENT);
movementNames.add(ABORT_MOVEMENT);

View File

@ -36,7 +36,7 @@ export class StateManager {
this.state = {
pieceName: config.name,
currentMovement: config.initialMovement,
currentMovement: options.startMovement ?? config.initialMovement,
iteration: 0,
movementOutputs: new Map(),
lastOutput: undefined,

View File

@ -94,6 +94,10 @@ export class InstructionBuilder {
const pieceDescription = this.context.pieceDescription ?? '';
const hasPieceDescription = !!pieceDescription;
// Retry note
const hasRetryNote = !!this.context.retryNote;
const retryNote = hasRetryNote ? escapeTemplateChars(this.context.retryNote!) : '';
return loadTemplate('perform_phase1_message', language, {
workingDirectory: this.context.cwd,
editRule,
@ -113,6 +117,8 @@ export class InstructionBuilder {
previousResponse,
hasUserInputs,
userInputs,
hasRetryNote,
retryNote,
instructions,
});
}

View File

@ -40,6 +40,8 @@ export interface InstructionContext {
pieceName?: string;
/** Piece description (optional) */
pieceDescription?: string;
/** Retry note explaining why task is being retried */
retryNote?: string;
}
/**

View File

@ -183,6 +183,10 @@ export interface PieceEngineOptions {
detectRuleIndex?: RuleIndexDetector;
/** AI judge caller (required for rules evaluation) */
callAiJudge?: AiJudgeCaller;
/** Override initial movement (default: piece config's initialMovement) */
startMovement?: string;
/** Retry note explaining why task is being retried */
retryNote?: string;
}
/** Loop detection result */

View File

@ -228,6 +228,8 @@ export async function executePiece(
interactive: interactiveUserInput,
detectRuleIndex,
callAiJudge,
startMovement: options.startMovement,
retryNote: options.retryNote,
});
let abortReason: string | undefined;

View File

@ -25,7 +25,7 @@ const log = createLogger('task');
* Execute a single task with piece.
*/
export async function executeTask(options: ExecuteTaskOptions): Promise<boolean> {
const { task, cwd, pieceIdentifier, projectCwd, agentOverrides, interactiveUserInput, interactiveMetadata } = options;
const { task, cwd, pieceIdentifier, projectCwd, agentOverrides, interactiveUserInput, interactiveMetadata, startMovement, retryNote } = options;
const pieceConfig = loadPieceByIdentifier(pieceIdentifier, projectCwd);
if (!pieceConfig) {
@ -52,6 +52,8 @@ export async function executeTask(options: ExecuteTaskOptions): Promise<boolean>
model: agentOverrides?.model,
interactiveUserInput,
interactiveMetadata,
startMovement,
retryNote,
});
return result.success;
}
@ -75,7 +77,7 @@ export async function executeAndCompleteTask(
const executionLog: string[] = [];
try {
const { execCwd, execPiece, isWorktree } = await resolveTaskExecution(task, cwd, pieceName);
const { execCwd, execPiece, isWorktree, startMovement, retryNote } = await resolveTaskExecution(task, cwd, pieceName);
// cwd is always the project root; pass it as projectCwd so reports/sessions go there
const taskSuccess = await executeTask({
@ -84,6 +86,8 @@ export async function executeAndCompleteTask(
pieceIdentifier: execPiece,
projectCwd: cwd,
agentOverrides: options,
startMovement,
retryNote,
});
const completedAt = new Date().toISOString();
@ -194,7 +198,7 @@ export async function resolveTaskExecution(
task: TaskInfo,
defaultCwd: string,
defaultPiece: string
): Promise<{ execCwd: string; execPiece: string; isWorktree: boolean; branch?: string }> {
): Promise<{ execCwd: string; execPiece: string; isWorktree: boolean; branch?: string; startMovement?: string; retryNote?: string }> {
const data = task.data;
// No structured data: use defaults
@ -227,5 +231,11 @@ export async function resolveTaskExecution(
// Handle piece override
const execPiece = data.piece || defaultPiece;
return { execCwd, execPiece, isWorktree, branch };
// Handle start_movement override
const startMovement = data.start_movement;
// Handle retry_note
const retryNote = data.retry_note;
return { execCwd, execPiece, isWorktree, branch, startMovement, retryNote };
}

View File

@ -34,6 +34,10 @@ export interface PieceExecutionOptions {
interactiveUserInput?: boolean;
/** Interactive mode result metadata for NDJSON logging */
interactiveMetadata?: InteractiveMetadata;
/** Override initial movement (default: piece config's initialMovement) */
startMovement?: string;
/** Retry note explaining why task is being retried */
retryNote?: string;
}
export interface TaskExecutionOptions {
@ -56,6 +60,10 @@ export interface ExecuteTaskOptions {
interactiveUserInput?: boolean;
/** Interactive mode result metadata for NDJSON logging */
interactiveMetadata?: InteractiveMetadata;
/** Override initial movement (default: piece config's initialMovement) */
startMovement?: string;
/** Retry note explaining why task is being retried */
retryNote?: string;
}
export interface PipelineExecutionOptions {

View File

@ -28,6 +28,7 @@ import {
instructBranch,
} from './taskActions.js';
import { deletePendingTask, deleteFailedTask } from './taskDeleteActions.js';
import { retryFailedTask } from './taskRetryActions.js';
import { listTasksNonInteractive, type ListNonInteractiveOptions } from './listNonInteractive.js';
export type { ListNonInteractiveOptions } from './listNonInteractive.js';
@ -42,14 +43,17 @@ export {
instructBranch,
} from './taskActions.js';
/** Task action type for the task action selection menu */
type TaskAction = 'delete';
/** Task action type for pending task action selection menu */
type PendingTaskAction = 'delete';
/** Task action type for failed task action selection menu */
type FailedTaskAction = 'retry' | 'delete';
/**
* Show task details and prompt for an action.
* Show pending task details and prompt for an action.
* Returns the selected action, or null if cancelled.
*/
async function showTaskAndPromptAction(task: TaskListItem): Promise<TaskAction | null> {
async function showPendingTaskAndPromptAction(task: TaskListItem): Promise<PendingTaskAction | null> {
header(`[${task.kind}] ${task.name}`);
info(` Created: ${task.createdAt}`);
if (task.content) {
@ -57,12 +61,33 @@ async function showTaskAndPromptAction(task: TaskListItem): Promise<TaskAction |
}
blankLine();
return await selectOption<TaskAction>(
return await selectOption<PendingTaskAction>(
`Action for ${task.name}:`,
[{ label: 'Delete', value: 'delete', description: 'Remove this task permanently' }],
);
}
/**
* Show failed task details and prompt for an action.
* Returns the selected action, or null if cancelled.
*/
async function showFailedTaskAndPromptAction(task: TaskListItem): Promise<FailedTaskAction | null> {
header(`[${task.kind}] ${task.name}`);
info(` Failed at: ${task.createdAt}`);
if (task.content) {
info(` ${task.content}`);
}
blankLine();
return await selectOption<FailedTaskAction>(
`Action for ${task.name}:`,
[
{ label: 'Retry', value: 'retry', description: 'Requeue task and select start movement' },
{ label: 'Delete', value: 'delete', description: 'Remove this task permanently' },
],
);
}
/**
* Main entry point: list branch-based tasks interactively.
*/
@ -170,15 +195,17 @@ export async function listTasks(
} else if (type === 'pending') {
const task = pendingTasks[idx];
if (!task) continue;
const taskAction = await showTaskAndPromptAction(task);
const taskAction = await showPendingTaskAndPromptAction(task);
if (taskAction === 'delete') {
await deletePendingTask(task);
}
} else if (type === 'failed') {
const task = failedTasks[idx];
if (!task) continue;
const taskAction = await showTaskAndPromptAction(task);
if (taskAction === 'delete') {
const taskAction = await showFailedTaskAndPromptAction(task);
if (taskAction === 'retry') {
await retryFailedTask(task, cwd);
} else if (taskAction === 'delete') {
await deleteFailedTask(task);
}
}

View File

@ -0,0 +1,241 @@
/**
* Retry actions for failed tasks.
*
* Provides interactive retry functionality including
* failure info display and movement selection.
*/
import { join } from 'node:path';
import { existsSync, readdirSync } from 'node:fs';
import type { TaskListItem } from '../../../infra/task/index.js';
import { TaskRunner, parseTaskFile, type TaskFileData } from '../../../infra/task/index.js';
import { extractFailureInfo, type FailureInfo } from '../../../infra/fs/session.js';
import { loadPieceByIdentifier, loadGlobalConfig } from '../../../infra/config/index.js';
import { selectOption, promptInput } from '../../../shared/prompt/index.js';
import { success, error as logError, info, header, blankLine, status } from '../../../shared/ui/index.js';
import { createLogger, getErrorMessage } from '../../../shared/utils/index.js';
import type { PieceConfig } from '../../../core/models/index.js';
const log = createLogger('list-tasks');
/**
* Find the session log file path from a failed task directory.
* Looks in .takt/logs/ for a matching session ID from log.json.
*/
function findSessionLogPath(failedTaskDir: string, projectDir: string): string | null {
const logsDir = join(projectDir, '.takt', 'logs');
if (!existsSync(logsDir)) return null;
// Try to find the log file
// Failed tasks don't have sessionId in log.json by default,
// so we look for the most recent log file that matches the failure time
const logJsonPath = join(failedTaskDir, 'log.json');
if (!existsSync(logJsonPath)) return null;
try {
// List all .jsonl files in logs dir
const logFiles = readdirSync(logsDir).filter((f) => f.endsWith('.jsonl'));
if (logFiles.length === 0) return null;
// Get the failed task timestamp from directory name
const dirName = failedTaskDir.split('/').pop();
if (!dirName) return null;
const underscoreIdx = dirName.indexOf('_');
if (underscoreIdx === -1) return null;
const timestampRaw = dirName.slice(0, underscoreIdx);
// Convert format: 2026-01-31T12-00-00 -> 20260131-120000
const normalizedTimestamp = timestampRaw
.replace(/-/g, '')
.replace('T', '-');
// Find logs that match the date (first 8 chars of normalized timestamp)
const datePrefix = normalizedTimestamp.slice(0, 8);
const matchingLogs = logFiles
.filter((f) => f.startsWith(datePrefix))
.sort()
.reverse(); // Most recent first
// Return the most recent matching log
if (matchingLogs.length > 0) {
return join(logsDir, matchingLogs[0]!);
}
} catch {
// Ignore errors
}
return null;
}
/**
* Find and parse the task file from a failed task directory.
* Returns the parsed TaskFileData if found, null otherwise.
*/
function parseFailedTaskFile(failedTaskDir: string): TaskFileData | null {
const taskExtensions = ['.yaml', '.yml', '.md'];
let files: string[];
try {
files = readdirSync(failedTaskDir);
} catch {
return null;
}
for (const file of files) {
const ext = file.slice(file.lastIndexOf('.'));
if (file === 'report.md' || file === 'log.json') continue;
if (!taskExtensions.includes(ext)) continue;
try {
const taskFilePath = join(failedTaskDir, file);
const parsed = parseTaskFile(taskFilePath);
return parsed.data;
} catch {
continue;
}
}
return null;
}
/**
* Display failure information for a failed task.
*/
function displayFailureInfo(task: TaskListItem, failureInfo: FailureInfo | null): void {
header(`Failed Task: ${task.name}`);
info(` Failed at: ${task.createdAt}`);
if (failureInfo) {
blankLine();
if (failureInfo.lastCompletedMovement) {
status('Last completed', failureInfo.lastCompletedMovement);
}
if (failureInfo.failedMovement) {
status('Failed at', failureInfo.failedMovement, 'red');
}
status('Iterations', String(failureInfo.iterations));
if (failureInfo.errorMessage) {
status('Error', failureInfo.errorMessage, 'red');
}
} else {
blankLine();
info(' (No session log found - failure details unavailable)');
}
blankLine();
}
/**
* Prompt user to select a movement to start from.
* Returns the selected movement name, or null if cancelled.
*/
async function selectStartMovement(
pieceConfig: PieceConfig,
defaultMovement: string | null,
): Promise<string | null> {
const movements = pieceConfig.movements.map((m) => m.name);
// Determine default selection
const defaultIdx = defaultMovement
? movements.indexOf(defaultMovement)
: 0;
const effectiveDefault = defaultIdx >= 0 ? movements[defaultIdx] : movements[0];
const options = movements.map((name) => ({
label: name === effectiveDefault ? `${name} (default)` : name,
value: name,
description: name === pieceConfig.initialMovement ? 'Initial movement' : undefined,
}));
return await selectOption<string>('Start from movement:', options);
}
/**
* Retry a failed task.
* Shows failure info, prompts for movement selection, and requeues the task.
*
* @returns true if task was requeued, false if cancelled
*/
export async function retryFailedTask(
task: TaskListItem,
projectDir: string,
): Promise<boolean> {
// Find session log and extract failure info
const sessionLogPath = findSessionLogPath(task.filePath, projectDir);
const failureInfo = sessionLogPath ? extractFailureInfo(sessionLogPath) : null;
// Display failure information
displayFailureInfo(task, failureInfo);
// Parse the failed task file to get the piece field
const taskFileData = parseFailedTaskFile(task.filePath);
// Determine piece name: task file -> global config -> 'default'
const globalConfig = loadGlobalConfig();
const pieceName = taskFileData?.piece ?? globalConfig.defaultPiece ?? 'default';
const pieceConfig = loadPieceByIdentifier(pieceName, projectDir);
if (!pieceConfig) {
logError(`Piece "${pieceName}" not found. Cannot determine available movements.`);
return false;
}
// Prompt for movement selection
// Default to failed movement, or last completed + 1, or initial movement
let defaultMovement: string | null = null;
if (failureInfo?.failedMovement) {
defaultMovement = failureInfo.failedMovement;
} else if (failureInfo?.lastCompletedMovement) {
// Find the next movement after the last completed one
const movements = pieceConfig.movements.map((m) => m.name);
const lastIdx = movements.indexOf(failureInfo.lastCompletedMovement);
if (lastIdx >= 0 && lastIdx < movements.length - 1) {
defaultMovement = movements[lastIdx + 1] ?? null;
}
}
const selectedMovement = await selectStartMovement(pieceConfig, defaultMovement);
if (selectedMovement === null) {
return false; // User cancelled
}
// Prompt for retry note (optional)
blankLine();
const retryNote = await promptInput('Retry note (optional, press Enter to skip):');
const trimmedNote = retryNote?.trim();
// Requeue the task
try {
const runner = new TaskRunner(projectDir);
// Only pass startMovement if it's different from the initial movement
const startMovement = selectedMovement !== pieceConfig.initialMovement
? selectedMovement
: undefined;
const requeuedPath = runner.requeueFailedTask(
task.filePath,
startMovement,
trimmedNote || undefined
);
success(`Task requeued: ${task.name}`);
if (startMovement) {
info(` Will start from: ${startMovement}`);
}
if (trimmedNote) {
info(` Retry note: ${trimmedNote}`);
}
info(` Task file: ${requeuedPath}`);
log.info('Requeued failed task', {
name: task.name,
from: task.filePath,
to: requeuedPath,
startMovement,
retryNote: trimmedNote,
});
return true;
} catch (err) {
const msg = getErrorMessage(err);
logError(`Failed to requeue task: ${msg}`);
log.error('Failed to requeue task', { name: task.name, error: msg });
return false;
}
}

View File

@ -28,6 +28,20 @@ export type {
LatestLogPointer,
} from '../../shared/utils/index.js';
/** Failure information extracted from session log */
export interface FailureInfo {
/** Last movement that completed successfully */
lastCompletedMovement: string | null;
/** Movement that was in progress when failure occurred */
failedMovement: string | null;
/** Total iterations consumed */
iterations: number;
/** Error message from piece_abort record */
errorMessage: string | null;
/** Session ID extracted from log file name */
sessionId: string | null;
}
/**
* Manages session lifecycle: ID generation, NDJSON logging,
* session log creation/loading, and latest pointer maintenance.
@ -298,3 +312,67 @@ export function updateLatestPointer(
): void {
defaultManager.updateLatestPointer(log, sessionId, projectDir, options);
}
/**
* Extract failure information from an NDJSON session log file.
*
* @param filepath - Path to the .jsonl session log file
* @returns FailureInfo or null if file doesn't exist or is invalid
*/
export function extractFailureInfo(filepath: string): FailureInfo | null {
if (!existsSync(filepath)) {
return null;
}
const content = readFileSync(filepath, 'utf-8');
const lines = content.trim().split('\n').filter((line) => line.length > 0);
if (lines.length === 0) return null;
let lastCompletedMovement: string | null = null;
let failedMovement: string | null = null;
let iterations = 0;
let errorMessage: string | null = null;
let lastStepStartMovement: string | null = null;
// Extract sessionId from filename (e.g., "20260205-120000-abc123.jsonl" -> "20260205-120000-abc123")
const filename = filepath.split('/').pop();
const sessionId = filename?.replace(/\.jsonl$/, '') ?? null;
for (const line of lines) {
try {
const record = JSON.parse(line) as NdjsonRecord;
switch (record.type) {
case 'step_start':
// Track the movement that started (may fail before completing)
lastStepStartMovement = record.step;
break;
case 'step_complete':
// Track the last successfully completed movement
lastCompletedMovement = record.step;
iterations++;
// Reset lastStepStartMovement since this movement completed
lastStepStartMovement = null;
break;
case 'piece_abort':
// If there was a step_start without a step_complete, that's the failed movement
failedMovement = lastStepStartMovement;
errorMessage = record.reason;
break;
}
} catch {
// Skip malformed JSON lines
continue;
}
}
return {
lastCompletedMovement,
failedMovement,
iterations,
errorMessage,
sessionId,
};
}

View File

@ -204,6 +204,91 @@ export class TaskRunner {
return '';
}
/**
* Requeue a failed task back to .takt/tasks/
*
* Copies the task file from failed directory to tasks directory.
* If startMovement is specified and the task is YAML, adds start_movement field.
* If retryNote is specified and the task is YAML, adds retry_note field.
* Original failed directory is preserved for history.
*
* @param failedTaskDir - Path to failed task directory (e.g., .takt/failed/2026-01-31T12-00-00_my-task/)
* @param startMovement - Optional movement to start from (written to task file)
* @param retryNote - Optional note about why task is being retried (written to task file)
* @returns The path to the requeued task file
* @throws Error if task file not found or copy fails
*/
requeueFailedTask(failedTaskDir: string, startMovement?: string, retryNote?: string): string {
this.ensureDirs();
// Find task file in failed directory
const taskExtensions = ['.yaml', '.yml', '.md'];
let files: string[];
try {
files = fs.readdirSync(failedTaskDir);
} catch (err) {
throw new Error(`Failed to read failed task directory: ${failedTaskDir} - ${err}`);
}
let taskFile: string | null = null;
let taskExt: string | null = null;
for (const file of files) {
const ext = path.extname(file);
if (file === 'report.md' || file === 'log.json') continue;
if (!taskExtensions.includes(ext)) continue;
taskFile = path.join(failedTaskDir, file);
taskExt = ext;
break;
}
if (!taskFile || !taskExt) {
throw new Error(`No task file found in failed directory: ${failedTaskDir}`);
}
// Read task content
const taskContent = fs.readFileSync(taskFile, 'utf-8');
const taskName = path.basename(taskFile, taskExt);
// Destination path
const destFile = path.join(this.tasksDir, `${taskName}${taskExt}`);
// For YAML files, add start_movement and retry_note if specified
let finalContent = taskContent;
if (taskExt === '.yaml' || taskExt === '.yml') {
if (startMovement) {
// Check if start_movement already exists
if (!/^start_movement:/m.test(finalContent)) {
// Add start_movement field at the end
finalContent = finalContent.trimEnd() + `\nstart_movement: ${startMovement}\n`;
} else {
// Replace existing start_movement
finalContent = finalContent.replace(/^start_movement:.*$/m, `start_movement: ${startMovement}`);
}
}
if (retryNote) {
// Escape double quotes in retry note for YAML string
const escapedNote = retryNote.replace(/"/g, '\\"');
// Check if retry_note already exists
if (!/^retry_note:/m.test(finalContent)) {
// Add retry_note field at the end
finalContent = finalContent.trimEnd() + `\nretry_note: "${escapedNote}"\n`;
} else {
// Replace existing retry_note
finalContent = finalContent.replace(/^retry_note:.*$/m, `retry_note: "${escapedNote}"`);
}
}
}
// Write to tasks directory
fs.writeFileSync(destFile, finalContent, 'utf-8');
log.info('Requeued failed task', { from: failedTaskDir, to: destFile, startMovement });
return destFile;
}
/**
*
*/

View File

@ -30,6 +30,8 @@ export const TaskFileSchema = z.object({
branch: z.string().optional(),
piece: z.string().optional(),
issue: z.number().int().positive().optional(),
start_movement: z.string().optional(),
retry_note: z.string().optional(),
});
export type TaskFileData = z.infer<typeof TaskFileSchema>;

View File

@ -4,7 +4,7 @@
vars: workingDirectory, editRule, pieceName, pieceDescription, hasPieceDescription,
pieceStructure, iteration, movementIteration, movement, hasReport, reportInfo,
phaseNote, hasTaskSection, userRequest, hasPreviousResponse, previousResponse,
hasUserInputs, userInputs, instructions
hasUserInputs, userInputs, hasRetryNote, retryNote, instructions
builder: InstructionBuilder
-->
## Execution Context
@ -29,6 +29,11 @@ Note: This section is metadata. Follow the language used in the rest of the prom
{{#if hasReport}}{{reportInfo}}
{{phaseNote}}{{/if}}
{{#if hasRetryNote}}
## Retry Note
{{retryNote}}
{{/if}}
{{#if hasTaskSection}}
## User Request

View File

@ -4,7 +4,7 @@
vars: workingDirectory, editRule, pieceName, pieceDescription, hasPieceDescription,
pieceStructure, iteration, movementIteration, movement, hasReport, reportInfo,
phaseNote, hasTaskSection, userRequest, hasPreviousResponse, previousResponse,
hasUserInputs, userInputs, instructions
hasUserInputs, userInputs, hasRetryNote, retryNote, instructions
builder: InstructionBuilder
-->
## 実行コンテキスト
@ -28,6 +28,11 @@
{{#if hasReport}}{{reportInfo}}
{{phaseNote}}{{/if}}
{{#if hasRetryNote}}
## 再投入メモ
{{retryNote}}
{{/if}}
{{#if hasTaskSection}}
## User Request