takt: github-issue-132-moodono-piisu (#144)

This commit is contained in:
nrs 2026-02-08 18:05:31 +09:00 committed by GitHub
parent 3167f038a4
commit 3533946602
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 818 additions and 40 deletions

View File

@ -53,7 +53,8 @@ vi.mock('../infra/config/loaders/pieceResolver.js', () => ({
getPieceDescription: vi.fn(() => ({ getPieceDescription: vi.fn(() => ({
name: 'default', name: 'default',
description: '', description: '',
pieceStructure: '1. implement\n2. review' pieceStructure: '1. implement\n2. review',
movementPreviews: [],
})), })),
})); }));

View File

@ -48,7 +48,8 @@ vi.mock('../features/interactive/index.js', () => ({
})); }));
vi.mock('../infra/config/index.js', () => ({ vi.mock('../infra/config/index.js', () => ({
getPieceDescription: vi.fn(() => ({ name: 'default', description: 'test piece', pieceStructure: '' })), getPieceDescription: vi.fn(() => ({ name: 'default', description: 'test piece', pieceStructure: '', movementPreviews: [] })),
loadGlobalConfig: vi.fn(() => ({ interactivePreviewMovements: 3 })),
})); }));
vi.mock('../shared/constants.js', () => ({ vi.mock('../shared/constants.js', () => ({

View File

@ -0,0 +1,139 @@
/**
* Tests for formatMovementPreviews
*/
import { describe, it, expect } from 'vitest';
import type { MovementPreview } from '../infra/config/loaders/pieceResolver.js';
import { formatMovementPreviews } from '../features/interactive/interactive.js';
describe('formatMovementPreviews', () => {
const basePreviews: MovementPreview[] = [
{
name: 'plan',
personaDisplayName: 'Planner',
personaContent: 'You are a planner.',
instructionContent: 'Create a plan for {task}',
allowedTools: ['Read', 'Glob', 'Grep'],
canEdit: false,
},
{
name: 'implement',
personaDisplayName: 'Coder',
personaContent: 'You are a coder.',
instructionContent: 'Implement the plan.',
allowedTools: ['Read', 'Edit', 'Bash'],
canEdit: true,
},
];
it('should format previews with English labels', () => {
const result = formatMovementPreviews(basePreviews, 'en');
expect(result).toContain('### 1. plan (Planner)');
expect(result).toContain('**Persona:**');
expect(result).toContain('You are a planner.');
expect(result).toContain('**Instruction:**');
expect(result).toContain('Create a plan for {task}');
expect(result).toContain('**Tools:** Read, Glob, Grep');
expect(result).toContain('**Edit:** No');
expect(result).toContain('### 2. implement (Coder)');
expect(result).toContain('**Tools:** Read, Edit, Bash');
expect(result).toContain('**Edit:** Yes');
});
it('should format previews with Japanese labels', () => {
const result = formatMovementPreviews(basePreviews, 'ja');
expect(result).toContain('### 1. plan (Planner)');
expect(result).toContain('**ペルソナ:**');
expect(result).toContain('**インストラクション:**');
expect(result).toContain('**ツール:** Read, Glob, Grep');
expect(result).toContain('**編集:** 不可');
expect(result).toContain('**編集:** 可');
});
it('should show "None" when no tools are allowed (English)', () => {
const previews: MovementPreview[] = [
{
name: 'step',
personaDisplayName: 'Agent',
personaContent: 'Agent persona',
instructionContent: 'Do something',
allowedTools: [],
canEdit: false,
},
];
const result = formatMovementPreviews(previews, 'en');
expect(result).toContain('**Tools:** None');
});
it('should show "なし" when no tools are allowed (Japanese)', () => {
const previews: MovementPreview[] = [
{
name: 'step',
personaDisplayName: 'Agent',
personaContent: 'Agent persona',
instructionContent: 'Do something',
allowedTools: [],
canEdit: false,
},
];
const result = formatMovementPreviews(previews, 'ja');
expect(result).toContain('**ツール:** なし');
});
it('should skip empty persona content', () => {
const previews: MovementPreview[] = [
{
name: 'step',
personaDisplayName: 'Agent',
personaContent: '',
instructionContent: 'Do something',
allowedTools: [],
canEdit: false,
},
];
const result = formatMovementPreviews(previews, 'en');
expect(result).not.toContain('**Persona:**');
expect(result).toContain('**Instruction:**');
});
it('should skip empty instruction content', () => {
const previews: MovementPreview[] = [
{
name: 'step',
personaDisplayName: 'Agent',
personaContent: 'Some persona',
instructionContent: '',
allowedTools: [],
canEdit: false,
},
];
const result = formatMovementPreviews(previews, 'en');
expect(result).toContain('**Persona:**');
expect(result).not.toContain('**Instruction:**');
});
it('should return empty string for empty array', () => {
const result = formatMovementPreviews([], 'en');
expect(result).toBe('');
});
it('should separate multiple previews with double newline', () => {
const result = formatMovementPreviews(basePreviews, 'en');
// Two movements should be separated by \n\n
const parts = result.split('\n\n### ');
expect(parts.length).toBe(2);
});
});

View File

@ -287,6 +287,55 @@ describe('loadGlobalConfig', () => {
expect(config.notificationSound).toBeUndefined(); expect(config.notificationSound).toBeUndefined();
}); });
it('should load interactive_preview_movements config from config.yaml', () => {
const taktDir = join(testHomeDir, '.takt');
mkdirSync(taktDir, { recursive: true });
writeFileSync(
getGlobalConfigPath(),
'language: en\ninteractive_preview_movements: 5\n',
'utf-8',
);
const config = loadGlobalConfig();
expect(config.interactivePreviewMovements).toBe(5);
});
it('should save and reload interactive_preview_movements config', () => {
const taktDir = join(testHomeDir, '.takt');
mkdirSync(taktDir, { recursive: true });
writeFileSync(getGlobalConfigPath(), 'language: en\n', 'utf-8');
const config = loadGlobalConfig();
config.interactivePreviewMovements = 7;
saveGlobalConfig(config);
invalidateGlobalConfigCache();
const reloaded = loadGlobalConfig();
expect(reloaded.interactivePreviewMovements).toBe(7);
});
it('should default interactive_preview_movements to 3', () => {
const taktDir = join(testHomeDir, '.takt');
mkdirSync(taktDir, { recursive: true });
writeFileSync(getGlobalConfigPath(), 'language: en\n', 'utf-8');
const config = loadGlobalConfig();
expect(config.interactivePreviewMovements).toBe(3);
});
it('should accept interactive_preview_movements: 0 to disable', () => {
const taktDir = join(testHomeDir, '.takt');
mkdirSync(taktDir, { recursive: true });
writeFileSync(
getGlobalConfigPath(),
'language: en\ninteractive_preview_movements: 0\n',
'utf-8',
);
const config = loadGlobalConfig();
expect(config.interactivePreviewMovements).toBe(0);
});
describe('provider/model compatibility validation', () => { describe('provider/model compatibility validation', () => {
it('should throw when provider is codex but model is a Claude alias (opus)', () => { it('should throw when provider is codex but model is a Claude alias (opus)', () => {
const taktDir = join(testHomeDir, '.takt'); const taktDir = join(testHomeDir, '.takt');

View File

@ -1,9 +1,9 @@
/** /**
* Tests for getPieceDescription and buildWorkflowString * Tests for getPieceDescription, buildWorkflowString, and buildMovementPreviews
*/ */
import { describe, it, expect, beforeEach, afterEach } from 'vitest'; import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from 'node:fs'; import { mkdtempSync, writeFileSync, mkdirSync, rmSync, chmodSync } from 'node:fs';
import { join } from 'node:path'; import { join } from 'node:path';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
import { getPieceDescription } from '../infra/config/loaders/pieceResolver.js'; import { getPieceDescription } from '../infra/config/loaders/pieceResolver.js';
@ -49,6 +49,7 @@ movements:
expect(result.pieceStructure).toBe( expect(result.pieceStructure).toBe(
'1. plan (タスク計画)\n2. implement (実装)\n3. review' '1. plan (タスク計画)\n2. implement (実装)\n3. review'
); );
expect(result.movementPreviews).toEqual([]);
}); });
it('should return workflow structure with parallel movements', () => { it('should return workflow structure with parallel movements', () => {
@ -91,6 +92,7 @@ movements:
' - arch_review\n' + ' - arch_review\n' +
'3. fix (修正)' '3. fix (修正)'
); );
expect(result.movementPreviews).toEqual([]);
}); });
it('should handle movements without descriptions', () => { it('should handle movements without descriptions', () => {
@ -115,6 +117,7 @@ movements:
expect(result.name).toBe('minimal'); expect(result.name).toBe('minimal');
expect(result.description).toBe(''); expect(result.description).toBe('');
expect(result.pieceStructure).toBe('1. step1\n2. step2'); expect(result.pieceStructure).toBe('1. step1\n2. step2');
expect(result.movementPreviews).toEqual([]);
}); });
it('should return empty strings when piece is not found', () => { it('should return empty strings when piece is not found', () => {
@ -123,6 +126,7 @@ movements:
expect(result.name).toBe('nonexistent'); expect(result.name).toBe('nonexistent');
expect(result.description).toBe(''); expect(result.description).toBe('');
expect(result.pieceStructure).toBe(''); expect(result.pieceStructure).toBe('');
expect(result.movementPreviews).toEqual([]);
}); });
it('should handle parallel movements without descriptions', () => { it('should handle parallel movements without descriptions', () => {
@ -151,5 +155,411 @@ movements:
' - child1\n' + ' - child1\n' +
' - child2' ' - child2'
); );
expect(result.movementPreviews).toEqual([]);
});
});
describe('getPieceDescription with movementPreviews', () => {
let tempDir: string;
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), 'takt-test-previews-'));
});
afterEach(() => {
rmSync(tempDir, { recursive: true, force: true });
});
it('should return movement previews when previewCount is specified', () => {
const pieceYaml = `name: preview-test
description: Test piece
initial_movement: plan
max_iterations: 5
movements:
- name: plan
description: Planning
persona: Plan the task
instruction: "Create a plan for {task}"
allowed_tools:
- Read
- Glob
rules:
- condition: plan complete
next: implement
- name: implement
description: Implementation
persona: Implement the code
instruction: "Implement according to plan"
edit: true
allowed_tools:
- Read
- Edit
- Bash
rules:
- condition: done
next: review
- name: review
persona: Review the code
instruction: "Review changes"
rules:
- condition: approved
next: COMPLETE
`;
const piecePath = join(tempDir, 'preview-test.yaml');
writeFileSync(piecePath, pieceYaml);
const result = getPieceDescription(piecePath, tempDir, 3);
expect(result.movementPreviews).toHaveLength(3);
// First movement: plan
expect(result.movementPreviews[0].name).toBe('plan');
expect(result.movementPreviews[0].personaContent).toBe('Plan the task');
expect(result.movementPreviews[0].instructionContent).toBe('Create a plan for {task}');
expect(result.movementPreviews[0].allowedTools).toEqual(['Read', 'Glob']);
expect(result.movementPreviews[0].canEdit).toBe(false);
// Second movement: implement
expect(result.movementPreviews[1].name).toBe('implement');
expect(result.movementPreviews[1].personaContent).toBe('Implement the code');
expect(result.movementPreviews[1].instructionContent).toBe('Implement according to plan');
expect(result.movementPreviews[1].allowedTools).toEqual(['Read', 'Edit', 'Bash']);
expect(result.movementPreviews[1].canEdit).toBe(true);
// Third movement: review
expect(result.movementPreviews[2].name).toBe('review');
expect(result.movementPreviews[2].personaContent).toBe('Review the code');
expect(result.movementPreviews[2].canEdit).toBe(false);
});
it('should return empty previews when previewCount is 0', () => {
const pieceYaml = `name: test
initial_movement: step1
max_iterations: 1
movements:
- name: step1
persona: agent
instruction: "Do step1"
`;
const piecePath = join(tempDir, 'test.yaml');
writeFileSync(piecePath, pieceYaml);
const result = getPieceDescription(piecePath, tempDir, 0);
expect(result.movementPreviews).toEqual([]);
});
it('should return empty previews when previewCount is not specified', () => {
const pieceYaml = `name: test
initial_movement: step1
max_iterations: 1
movements:
- name: step1
persona: agent
instruction: "Do step1"
`;
const piecePath = join(tempDir, 'test.yaml');
writeFileSync(piecePath, pieceYaml);
const result = getPieceDescription(piecePath, tempDir);
expect(result.movementPreviews).toEqual([]);
});
it('should stop at COMPLETE movement', () => {
const pieceYaml = `name: test-complete
initial_movement: step1
max_iterations: 3
movements:
- name: step1
persona: agent1
instruction: "Step 1"
rules:
- condition: done
next: COMPLETE
- name: step2
persona: agent2
instruction: "Step 2"
`;
const piecePath = join(tempDir, 'test-complete.yaml');
writeFileSync(piecePath, pieceYaml);
const result = getPieceDescription(piecePath, tempDir, 5);
expect(result.movementPreviews).toHaveLength(1);
expect(result.movementPreviews[0].name).toBe('step1');
});
it('should stop at ABORT movement', () => {
const pieceYaml = `name: test-abort
initial_movement: step1
max_iterations: 3
movements:
- name: step1
persona: agent1
instruction: "Step 1"
rules:
- condition: abort
next: ABORT
- name: step2
persona: agent2
instruction: "Step 2"
`;
const piecePath = join(tempDir, 'test-abort.yaml');
writeFileSync(piecePath, pieceYaml);
const result = getPieceDescription(piecePath, tempDir, 5);
expect(result.movementPreviews).toHaveLength(1);
expect(result.movementPreviews[0].name).toBe('step1');
});
it('should read persona content from file when personaPath is set', () => {
const personaContent = '# Planner Persona\nYou are a planning expert.';
const personaPath = join(tempDir, 'planner.md');
writeFileSync(personaPath, personaContent);
const pieceYaml = `name: test-persona-file
initial_movement: plan
max_iterations: 1
personas:
planner: ./planner.md
movements:
- name: plan
persona: planner
instruction: "Plan the task"
`;
const piecePath = join(tempDir, 'test-persona-file.yaml');
writeFileSync(piecePath, pieceYaml);
const result = getPieceDescription(piecePath, tempDir, 1);
expect(result.movementPreviews).toHaveLength(1);
expect(result.movementPreviews[0].name).toBe('plan');
expect(result.movementPreviews[0].personaContent).toBe(personaContent);
});
it('should limit previews to maxCount', () => {
const pieceYaml = `name: test-limit
initial_movement: step1
max_iterations: 5
movements:
- name: step1
persona: agent1
instruction: "Step 1"
rules:
- condition: done
next: step2
- name: step2
persona: agent2
instruction: "Step 2"
rules:
- condition: done
next: step3
- name: step3
persona: agent3
instruction: "Step 3"
`;
const piecePath = join(tempDir, 'test-limit.yaml');
writeFileSync(piecePath, pieceYaml);
const result = getPieceDescription(piecePath, tempDir, 2);
expect(result.movementPreviews).toHaveLength(2);
expect(result.movementPreviews[0].name).toBe('step1');
expect(result.movementPreviews[1].name).toBe('step2');
});
it('should handle movements without rules (stop after first)', () => {
const pieceYaml = `name: test-no-rules
initial_movement: step1
max_iterations: 3
movements:
- name: step1
persona: agent1
instruction: "Step 1"
- name: step2
persona: agent2
instruction: "Step 2"
`;
const piecePath = join(tempDir, 'test-no-rules.yaml');
writeFileSync(piecePath, pieceYaml);
const result = getPieceDescription(piecePath, tempDir, 3);
expect(result.movementPreviews).toHaveLength(1);
expect(result.movementPreviews[0].name).toBe('step1');
});
it('should return empty previews when initial movement not found in list', () => {
const pieceYaml = `name: test-missing-initial
initial_movement: nonexistent
max_iterations: 1
movements:
- name: step1
persona: agent
instruction: "Do something"
`;
const piecePath = join(tempDir, 'test-missing-initial.yaml');
writeFileSync(piecePath, pieceYaml);
const result = getPieceDescription(piecePath, tempDir, 3);
expect(result.movementPreviews).toEqual([]);
});
it('should handle self-referencing rule (prevent infinite loop)', () => {
const pieceYaml = `name: test-self-ref
initial_movement: step1
max_iterations: 5
movements:
- name: step1
persona: agent1
instruction: "Step 1"
rules:
- condition: loop
next: step1
`;
const piecePath = join(tempDir, 'test-self-ref.yaml');
writeFileSync(piecePath, pieceYaml);
const result = getPieceDescription(piecePath, tempDir, 5);
expect(result.movementPreviews).toHaveLength(1);
expect(result.movementPreviews[0].name).toBe('step1');
});
it('should handle multi-node cycle A→B→A (prevent duplicate previews)', () => {
const pieceYaml = `name: test-cycle
initial_movement: stepA
max_iterations: 10
movements:
- name: stepA
persona: agentA
instruction: "Step A"
rules:
- condition: next
next: stepB
- name: stepB
persona: agentB
instruction: "Step B"
rules:
- condition: back
next: stepA
`;
const piecePath = join(tempDir, 'test-cycle.yaml');
writeFileSync(piecePath, pieceYaml);
const result = getPieceDescription(piecePath, tempDir, 10);
expect(result.movementPreviews).toHaveLength(2);
expect(result.movementPreviews[0].name).toBe('stepA');
expect(result.movementPreviews[1].name).toBe('stepB');
});
it('should return empty movementPreviews when piece is not found', () => {
const result = getPieceDescription('nonexistent', tempDir, 3);
expect(result.movementPreviews).toEqual([]);
});
it('should use inline persona content when no personaPath', () => {
const pieceYaml = `name: test-inline
initial_movement: step1
max_iterations: 1
movements:
- name: step1
persona: You are an inline persona
instruction: "Do something"
`;
const piecePath = join(tempDir, 'test-inline.yaml');
writeFileSync(piecePath, pieceYaml);
const result = getPieceDescription(piecePath, tempDir, 1);
expect(result.movementPreviews).toHaveLength(1);
expect(result.movementPreviews[0].personaContent).toBe('You are an inline persona');
});
it('should fallback to empty personaContent when personaPath file becomes unreadable', () => {
// Create the persona file so it passes existsSync during parsing
const personaPath = join(tempDir, 'unreadable-persona.md');
writeFileSync(personaPath, '# Persona content');
// Make the file unreadable so readFileSync fails in buildMovementPreviews
chmodSync(personaPath, 0o000);
const pieceYaml = `name: test-unreadable-persona
initial_movement: plan
max_iterations: 1
personas:
planner: ./unreadable-persona.md
movements:
- name: plan
persona: planner
instruction: "Plan the task"
`;
const piecePath = join(tempDir, 'test-unreadable-persona.yaml');
writeFileSync(piecePath, pieceYaml);
try {
const result = getPieceDescription(piecePath, tempDir, 1);
expect(result.movementPreviews).toHaveLength(1);
expect(result.movementPreviews[0].name).toBe('plan');
expect(result.movementPreviews[0].personaContent).toBe('');
expect(result.movementPreviews[0].instructionContent).toBe('Plan the task');
} finally {
// Restore permissions so cleanup can remove the file
chmodSync(personaPath, 0o644);
}
});
it('should include personaDisplayName in previews', () => {
const pieceYaml = `name: test-display
initial_movement: step1
max_iterations: 1
movements:
- name: step1
persona: agent
persona_name: Custom Agent Name
instruction: "Do something"
`;
const piecePath = join(tempDir, 'test-display.yaml');
writeFileSync(piecePath, pieceYaml);
const result = getPieceDescription(piecePath, tempDir, 1);
expect(result.movementPreviews).toHaveLength(1);
expect(result.movementPreviews[0].personaDisplayName).toBe('Custom Agent Name');
}); });
}); });

View File

@ -11,7 +11,7 @@ import { fetchIssue, formatIssueAsTask, checkGhCli, parseIssueNumbers, type GitH
import { selectAndExecuteTask, determinePiece, saveTaskFromInteractive, createIssueFromTask, type SelectAndExecuteOptions } from '../../features/tasks/index.js'; import { selectAndExecuteTask, determinePiece, saveTaskFromInteractive, createIssueFromTask, type SelectAndExecuteOptions } from '../../features/tasks/index.js';
import { executePipeline } from '../../features/pipeline/index.js'; import { executePipeline } from '../../features/pipeline/index.js';
import { interactiveMode } from '../../features/interactive/index.js'; import { interactiveMode } from '../../features/interactive/index.js';
import { getPieceDescription } 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';
@ -124,7 +124,9 @@ export async function executeDefaultAction(task?: string): Promise<void> {
return; return;
} }
const pieceContext = getPieceDescription(pieceId, resolvedCwd); const globalConfig = loadGlobalConfig();
const previewCount = globalConfig.interactivePreviewMovements;
const pieceContext = getPieceDescription(pieceId, resolvedCwd, previewCount);
const result = await interactiveMode(resolvedCwd, initialInput, pieceContext); const result = await interactiveMode(resolvedCwd, initialInput, pieceContext);
switch (result.action) { switch (result.action) {

View File

@ -67,6 +67,8 @@ export interface GlobalConfig {
preventSleep?: boolean; preventSleep?: boolean;
/** Enable notification sounds (default: true when undefined) */ /** Enable notification sounds (default: true when undefined) */
notificationSound?: boolean; notificationSound?: boolean;
/** Number of movement previews to inject into interactive mode (0 to disable, max 10) */
interactivePreviewMovements?: number;
/** Number of tasks to run concurrently in takt run (default: 1 = sequential) */ /** Number of tasks to run concurrently in takt run (default: 1 = sequential) */
concurrency: number; concurrency: number;
} }

View File

@ -318,6 +318,8 @@ export const GlobalConfigSchema = z.object({
prevent_sleep: z.boolean().optional(), prevent_sleep: z.boolean().optional(),
/** Enable notification sounds (default: true when undefined) */ /** Enable notification sounds (default: true when undefined) */
notification_sound: z.boolean().optional(), notification_sound: z.boolean().optional(),
/** Number of movement previews to inject into interactive mode (0 to disable, max 10) */
interactive_preview_movements: z.number().int().min(0).max(10).optional().default(3),
/** Number of tasks to run concurrently in takt run (default: 1 = sequential, max: 10) */ /** Number of tasks to run concurrently in takt run (default: 1 = sequential, max: 10) */
concurrency: z.number().int().min(1).max(10).optional().default(1), concurrency: z.number().int().min(1).max(10).optional().default(1),
}); });

View File

@ -19,6 +19,7 @@ import {
loadSessionState, loadSessionState,
clearSessionState, clearSessionState,
type SessionState, type SessionState,
type MovementPreview,
} from '../../infra/config/index.js'; } from '../../infra/config/index.js';
import { isQuietMode } from '../../shared/context.js'; import { isQuietMode } from '../../shared/context.js';
import { getProvider, type ProviderType } from '../../infra/providers/index.js'; import { getProvider, type ProviderType } from '../../infra/providers/index.js';
@ -90,8 +91,44 @@ function resolveLanguage(lang?: Language): 'en' | 'ja' {
return lang === 'ja' ? 'ja' : 'en'; return lang === 'ja' ? 'ja' : 'en';
} }
/**
* Format MovementPreview[] into a Markdown string for template injection.
* Each movement is rendered with its persona and instruction content.
*/
export function formatMovementPreviews(previews: MovementPreview[], lang: 'en' | 'ja'): string {
return previews.map((p, i) => {
const toolsStr = p.allowedTools.length > 0
? p.allowedTools.join(', ')
: (lang === 'ja' ? 'なし' : 'None');
const editStr = p.canEdit
? (lang === 'ja' ? '可' : 'Yes')
: (lang === 'ja' ? '不可' : 'No');
const personaLabel = lang === 'ja' ? 'ペルソナ' : 'Persona';
const instructionLabel = lang === 'ja' ? 'インストラクション' : 'Instruction';
const toolsLabel = lang === 'ja' ? 'ツール' : 'Tools';
const editLabel = lang === 'ja' ? '編集' : 'Edit';
const lines = [
`### ${i + 1}. ${p.name} (${p.personaDisplayName})`,
];
if (p.personaContent) {
lines.push(`**${personaLabel}:**`, p.personaContent);
}
if (p.instructionContent) {
lines.push(`**${instructionLabel}:**`, p.instructionContent);
}
lines.push(`**${toolsLabel}:** ${toolsStr}`, `**${editLabel}:** ${editStr}`);
return lines.join('\n');
}).join('\n\n');
}
function getInteractivePrompts(lang: 'en' | 'ja', pieceContext?: PieceContext) { function getInteractivePrompts(lang: 'en' | 'ja', pieceContext?: PieceContext) {
const systemPrompt = loadTemplate('score_interactive_system_prompt', lang, {}); const hasPreview = !!pieceContext?.movementPreviews?.length;
const systemPrompt = loadTemplate('score_interactive_system_prompt', lang, {
hasPiecePreview: hasPreview,
pieceStructure: pieceContext?.pieceStructure ?? '',
movementDetails: hasPreview ? formatMovementPreviews(pieceContext!.movementPreviews!, lang) : '',
});
const policyContent = loadTemplate('score_interactive_policy', lang, {}); const policyContent = loadTemplate('score_interactive_policy', lang, {});
return { return {
@ -149,10 +186,15 @@ function buildSummaryPrompt(
} }
const hasPiece = !!pieceContext; 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, { return loadTemplate('score_summary_system_prompt', lang, {
pieceInfo: hasPiece, pieceInfo: hasPiece,
pieceName: pieceContext?.name ?? '', pieceName: pieceContext?.name ?? '',
pieceDescription: pieceContext?.description ?? '', pieceDescription: pieceContext?.description ?? '',
movementDetails: summaryMovementDetails,
conversation, conversation,
}); });
} }
@ -220,6 +262,8 @@ export interface PieceContext {
description: string; description: string;
/** Piece structure (numbered list of movements) */ /** Piece structure (numbered list of movements) */
pieceStructure: string; pieceStructure: string;
/** Movement previews (persona + instruction content for first N movements) */
movementPreviews?: MovementPreview[];
} }
/** /**

View File

@ -11,7 +11,7 @@ import { stringify as stringifyYaml } from 'yaml';
import { promptInput, confirm } from '../../../shared/prompt/index.js'; import { promptInput, confirm } from '../../../shared/prompt/index.js';
import { success, info, error } from '../../../shared/ui/index.js'; import { success, info, error } from '../../../shared/ui/index.js';
import { summarizeTaskName, type TaskFileData } from '../../../infra/task/index.js'; import { summarizeTaskName, type TaskFileData } from '../../../infra/task/index.js';
import { getPieceDescription } from '../../../infra/config/index.js'; import { getPieceDescription, loadGlobalConfig } from '../../../infra/config/index.js';
import { determinePiece } from '../execute/selectAndExecute.js'; import { determinePiece } from '../execute/selectAndExecute.js';
import { createLogger, getErrorMessage } from '../../../shared/utils/index.js'; import { createLogger, getErrorMessage } from '../../../shared/utils/index.js';
import { isIssueReference, resolveIssueTask, parseIssueNumbers, createIssue } from '../../../infra/github/index.js'; import { isIssueReference, resolveIssueTask, parseIssueNumbers, createIssue } from '../../../infra/github/index.js';
@ -151,7 +151,9 @@ export async function addTask(cwd: string, task?: string): Promise<void> {
} }
piece = pieceId; piece = pieceId;
const pieceContext = getPieceDescription(pieceId, cwd); const globalConfig = loadGlobalConfig();
const previewCount = globalConfig.interactivePreviewMovements;
const pieceContext = getPieceDescription(pieceId, cwd, previewCount);
// Interactive mode: AI conversation to refine task // Interactive mode: AI conversation to refine task
const result = await interactiveMode(cwd, undefined, pieceContext); const result = await interactiveMode(cwd, undefined, pieceContext);

View File

@ -35,6 +35,7 @@ function createDefaultGlobalConfig(): GlobalConfig {
logLevel: 'info', logLevel: 'info',
provider: 'claude', provider: 'claude',
enableBuiltinPieces: true, enableBuiltinPieces: true,
interactivePreviewMovements: 3,
concurrency: 1, concurrency: 1,
}; };
} }
@ -107,6 +108,7 @@ export class GlobalConfigManager {
branchNameStrategy: parsed.branch_name_strategy, branchNameStrategy: parsed.branch_name_strategy,
preventSleep: parsed.prevent_sleep, preventSleep: parsed.prevent_sleep,
notificationSound: parsed.notification_sound, notificationSound: parsed.notification_sound,
interactivePreviewMovements: parsed.interactive_preview_movements,
concurrency: parsed.concurrency, concurrency: parsed.concurrency,
}; };
validateProviderModelCompatibility(config.provider, config.model); validateProviderModelCompatibility(config.provider, config.model);
@ -177,6 +179,9 @@ export class GlobalConfigManager {
if (config.notificationSound !== undefined) { if (config.notificationSound !== undefined) {
raw.notification_sound = config.notificationSound; raw.notification_sound = config.notificationSound;
} }
if (config.interactivePreviewMovements !== undefined) {
raw.interactive_preview_movements = config.interactivePreviewMovements;
}
if (config.concurrency !== undefined && config.concurrency > 1) { if (config.concurrency !== undefined && config.concurrency > 1) {
raw.concurrency = config.concurrency; raw.concurrency = config.concurrency;
} }

View File

@ -12,6 +12,7 @@ export {
loadAllPiecesWithSources, loadAllPiecesWithSources,
listPieces, listPieces,
listPieceEntries, listPieceEntries,
type MovementPreview,
type PieceDirEntry, type PieceDirEntry,
type PieceSource, type PieceSource,
type PieceWithSource, type PieceWithSource,

View File

@ -20,6 +20,7 @@ export {
loadAllPiecesWithSources, loadAllPiecesWithSources,
listPieces, listPieces,
listPieceEntries, listPieceEntries,
type MovementPreview,
type PieceDirEntry, type PieceDirEntry,
type PieceSource, type PieceSource,
type PieceWithSource, type PieceWithSource,

View File

@ -5,7 +5,7 @@
* using the priority chain: project-local user builtin. * using the priority chain: project-local user builtin.
*/ */
import { existsSync, readdirSync, statSync } from 'node:fs'; import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
import { join, resolve, isAbsolute } from 'node:path'; import { join, resolve, isAbsolute } from 'node:path';
import { homedir } from 'node:os'; import { homedir } from 'node:os';
import type { PieceConfig, PieceMovement } from '../../../core/models/index.js'; import type { PieceConfig, PieceMovement } from '../../../core/models/index.js';
@ -176,22 +176,100 @@ function buildWorkflowString(movements: PieceMovement[]): string {
return lines.join('\n'); return lines.join('\n');
} }
export interface MovementPreview {
/** Movement name (e.g., "plan") */
name: string;
/** Persona display name (e.g., "Planner") */
personaDisplayName: string;
/** Persona prompt content (read from personaPath file) */
personaContent: string;
/** Instruction template content (already resolved at parse time) */
instructionContent: string;
/** Allowed tools for this movement */
allowedTools: string[];
/** Whether this movement can edit files */
canEdit: boolean;
}
/**
* Build movement previews for the first N movements of a piece.
* Follows the execution order: initialMovement rules[0].next ...
*
* @param piece - Loaded PieceConfig
* @param maxCount - Maximum number of previews to build
* @returns Array of MovementPreview (may be shorter than maxCount)
*/
function buildMovementPreviews(piece: PieceConfig, maxCount: number): MovementPreview[] {
if (maxCount <= 0 || piece.movements.length === 0) return [];
const movementMap = new Map<string, PieceMovement>();
for (const m of piece.movements) {
movementMap.set(m.name, m);
}
const previews: MovementPreview[] = [];
const visited = new Set<string>();
let currentName: string | undefined = piece.initialMovement;
while (currentName && previews.length < maxCount) {
if (currentName === 'COMPLETE' || currentName === 'ABORT') break;
if (visited.has(currentName)) break;
visited.add(currentName);
const movement = movementMap.get(currentName);
if (!movement) break;
let personaContent = '';
if (movement.personaPath) {
try {
personaContent = readFileSync(movement.personaPath, 'utf-8');
} catch (err) {
log.debug('Failed to read persona file for preview', {
path: movement.personaPath,
error: getErrorMessage(err),
});
}
} else if (movement.persona) {
personaContent = movement.persona;
}
previews.push({
name: movement.name,
personaDisplayName: movement.personaDisplayName,
personaContent,
instructionContent: movement.instructionTemplate,
allowedTools: movement.allowedTools ?? [],
canEdit: movement.edit === true,
});
const nextName = movement.rules?.[0]?.next;
if (!nextName) break;
currentName = nextName;
}
return previews;
}
/** /**
* Get piece description by identifier. * Get piece description by identifier.
* Returns the piece name, description, and workflow structure. * Returns the piece name, description, workflow structure, and optional movement previews.
*/ */
export function getPieceDescription( export function getPieceDescription(
identifier: string, identifier: string,
projectCwd: string, projectCwd: string,
): { name: string; description: string; pieceStructure: string } { previewCount?: number,
): { name: string; description: string; pieceStructure: string; movementPreviews: MovementPreview[] } {
const piece = loadPieceByIdentifier(identifier, projectCwd); const piece = loadPieceByIdentifier(identifier, projectCwd);
if (!piece) { if (!piece) {
return { name: identifier, description: '', pieceStructure: '' }; return { name: identifier, description: '', pieceStructure: '', movementPreviews: [] };
} }
return { return {
name: piece.name, name: piece.name,
description: piece.description ?? '', description: piece.description ?? '',
pieceStructure: buildWorkflowString(piece.movements), pieceStructure: buildWorkflowString(piece.movements),
movementPreviews: previewCount && previewCount > 0
? buildMovementPreviews(piece, previewCount)
: [],
}; };
} }

View File

@ -138,7 +138,8 @@ export function resolveRefToContent(
} }
if (facetType && context) { if (facetType && context) {
return resolveFacetByName(ref, facetType, context); const facetContent = resolveFacetByName(ref, facetType, context);
if (facetContent !== undefined) return facetContent;
} }
return resolveResourceContent(ref, pieceDir); return resolveResourceContent(ref, pieceDir);

View File

@ -13,7 +13,7 @@ Focus on creating task instructions for the piece. Do not execute tasks or inves
| Principle | Standard | | Principle | Standard |
|-----------|----------| |-----------|----------|
| Focus on instruction creation | Task execution is always the piece's job | | Focus on instruction creation | Task execution is always the piece's job |
| Restrain investigation | Do not investigate unless explicitly requested | | Smart delegation | Delegate what agents can investigate on their own |
| Concise responses | Key points only. Avoid verbose explanations | | Concise responses | Key points only. Avoid verbose explanations |
## Understanding User Intent ## Understanding User Intent
@ -28,19 +28,19 @@ The user is NOT asking YOU to do the work, but asking you to create task instruc
## Investigation Guidelines ## Investigation Guidelines
### When Investigation IS Appropriate (Rare) ### When Investigation IS Appropriate
Only when the user explicitly asks YOU to investigate: When it improves instruction quality:
- "Read the README to understand the project structure" - Verifying file or module existence (narrowing targets)
- "Read file X to see what it does" - Understanding project structure (improving instruction accuracy)
- "What does this project do?" - When the user explicitly asks you to investigate
### When Investigation is NOT Appropriate (Most Cases) ### When Investigation is NOT Appropriate
When the user is describing a task for the piece: When agents can investigate on their own:
- "Review the changes" → Create instructions without investigating - Implementation details (code internals, dependency analysis)
- "Fix the code" → Create instructions without investigating - Determining how to make changes
- "Implement X" → Create instructions without investigating - Running tests or builds
## Strict Requirements ## Strict Requirements

View File

@ -1,7 +1,7 @@
<!-- <!--
template: score_interactive_system_prompt template: score_interactive_system_prompt
role: system prompt for interactive planning mode role: system prompt for interactive planning mode
vars: (none) vars: hasPiecePreview, pieceStructure, movementDetails
caller: features/interactive caller: features/interactive
--> -->
# Interactive Mode Assistant # Interactive Mode Assistant
@ -24,3 +24,22 @@ Handles TAKT's interactive mode, conversing with users to create task instructio
- Investigate codebase, understand prerequisites, identify target files (piece's job) - Investigate codebase, understand prerequisites, identify target files (piece's job)
- Execute tasks (piece's job) - Execute tasks (piece's job)
- Mention slash commands - Mention slash commands
{{#if hasPiecePreview}}
## Piece Structure
This task will be processed through the following workflow:
{{pieceStructure}}
### Agent Details
The following agents will process the task sequentially. Understand each agent's capabilities and instructions to improve the quality of your task instructions.
{{movementDetails}}
### Delegation Guidance
- Do not include excessive detail in instructions for things the agents above can investigate and determine on their own
- Clearly include information that agents cannot resolve on their own (user intent, priorities, constraints, etc.)
- Delegate codebase investigation, implementation details, and dependency analysis to the agents
{{/if}}

View File

@ -1,7 +1,7 @@
<!-- <!--
template: score_summary_system_prompt template: score_summary_system_prompt
role: system prompt for conversation-to-task summarization role: system prompt for conversation-to-task summarization
vars: pieceInfo, pieceName, pieceDescription, conversation vars: pieceInfo, pieceName, pieceDescription, movementDetails, 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.
@ -18,6 +18,7 @@ Requirements:
## Destination of Your Task Instruction ## Destination of Your Task Instruction
This task instruction will be passed to the "{{pieceName}}" piece. This task instruction will be passed to the "{{pieceName}}" piece.
Piece description: {{pieceDescription}} Piece description: {{pieceDescription}}
{{movementDetails}}
Create the instruction in the format expected by this piece. Create the instruction in the format expected by this piece.
{{/if}} {{/if}}

View File

@ -13,7 +13,7 @@
| 原則 | 基準 | | 原則 | 基準 |
|------|------| |------|------|
| 指示書作成に専念 | タスク実行は常にピースの仕事 | | 指示書作成に専念 | タスク実行は常にピースの仕事 |
| 調査の抑制 | 明示的な依頼がない限り調査しない | | スマートな委譲 | エージェントが調査できる内容は委ねる |
| 簡潔な返答 | 要点のみ。冗長な説明を避ける | | 簡潔な返答 | 要点のみ。冗長な説明を避ける |
## ユーザーの意図の理解 ## ユーザーの意図の理解
@ -28,19 +28,19 @@
## 調査の判断基準 ## 調査の判断基準
### 調査してよい場合(稀) ### 調査してよい場合
ユーザーが明示的に「あなた」に調査を依頼した場合のみ: 指示書の質を上げるために有益な場合:
- 「READMEを読んでプロジェクト構造を理解して」 - ファイルやモジュールの存在確認(対象の絞り込み)
- 「ファイルXを読んで何をしているか見て」 - プロジェクト構造の把握(指示書の精度向上)
- 「このプロジェクトは何をするもの?」 - ユーザーが明示的に調査を依頼した場合
### 調査してはいけない場合(ほとんど) ### 調査しない場合
ユーザーがピース向けのタスクを説明している場合: エージェントが自分で調査できる内容:
- 「変更をレビューして」→ 調査せずに指示書を作成 - 実装の詳細(コードの中身、依存関係の解析)
- 「コードを修正して」→ 調査せずに指示書を作成 - 変更方法の特定(どう修正するか)
- 「Xを実装して」→ 調査せずに指示書を作成 - テスト・ビルドの実行
## 厳守事項 ## 厳守事項

View File

@ -1,7 +1,7 @@
<!-- <!--
template: score_interactive_system_prompt template: score_interactive_system_prompt
role: system prompt for interactive planning mode role: system prompt for interactive planning mode
vars: (none) vars: hasPiecePreview, pieceStructure, movementDetails
caller: features/interactive caller: features/interactive
--> -->
# 対話モードアシスタント # 対話モードアシスタント
@ -24,3 +24,22 @@ TAKTの対話モードを担当し、ユーザーと会話してピース実行
- コードベース調査、前提把握、対象ファイル特定(ピースの仕事) - コードベース調査、前提把握、対象ファイル特定(ピースの仕事)
- タスクの実行(ピースの仕事) - タスクの実行(ピースの仕事)
- スラッシュコマンドへの言及 - スラッシュコマンドへの言及
{{#if hasPiecePreview}}
## ピース構成
このタスクは以下のワークフローで処理されます:
{{pieceStructure}}
### エージェント詳細
以下のエージェントが順次タスクを処理します。各エージェントの能力と指示内容を理解し、指示書の質を高めてください。
{{movementDetails}}
### 委譲ガイダンス
- 上記エージェントが自ら調査・判断できる内容は、指示書に過度な詳細を含める必要はありません
- エージェントが自力で解決できない情報(ユーザーの意図、優先度、制約条件など)を指示書に明確に含めてください
- コードベースの調査、実装詳細の特定、依存関係の解析はエージェントに委ねてください
{{/if}}

View File

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