From 35339466028fcf23933613bda9aae74c444c91be Mon Sep 17 00:00:00 2001 From: nrs <38722970+nrslib@users.noreply.github.com> Date: Sun, 8 Feb 2026 18:05:31 +0900 Subject: [PATCH] takt: github-issue-132-moodono-piisu (#144) --- src/__tests__/addTask.test.ts | 3 +- .../cli-routing-issue-resolve.test.ts | 3 +- src/__tests__/formatMovementPreviews.test.ts | 139 ++++++ src/__tests__/globalConfig-defaults.test.ts | 49 +++ src/__tests__/pieceResolver.test.ts | 414 +++++++++++++++++- src/app/cli/routing.ts | 6 +- src/core/models/global-config.ts | 2 + src/core/models/schemas.ts | 2 + src/features/interactive/interactive.ts | 46 +- src/features/tasks/add/index.ts | 6 +- src/infra/config/global/globalConfig.ts | 5 + src/infra/config/loaders/index.ts | 1 + src/infra/config/loaders/pieceLoader.ts | 1 + src/infra/config/loaders/pieceResolver.ts | 86 +++- src/infra/config/loaders/resource-resolver.ts | 3 +- .../prompts/en/score_interactive_policy.md | 22 +- .../en/score_interactive_system_prompt.md | 21 +- .../prompts/en/score_summary_system_prompt.md | 3 +- .../prompts/ja/score_interactive_policy.md | 22 +- .../ja/score_interactive_system_prompt.md | 21 +- .../prompts/ja/score_summary_system_prompt.md | 3 +- 21 files changed, 818 insertions(+), 40 deletions(-) create mode 100644 src/__tests__/formatMovementPreviews.test.ts diff --git a/src/__tests__/addTask.test.ts b/src/__tests__/addTask.test.ts index 96b1756..e00f40f 100644 --- a/src/__tests__/addTask.test.ts +++ b/src/__tests__/addTask.test.ts @@ -53,7 +53,8 @@ vi.mock('../infra/config/loaders/pieceResolver.js', () => ({ getPieceDescription: vi.fn(() => ({ name: 'default', description: '', - pieceStructure: '1. implement\n2. review' + pieceStructure: '1. implement\n2. review', + movementPreviews: [], })), })); diff --git a/src/__tests__/cli-routing-issue-resolve.test.ts b/src/__tests__/cli-routing-issue-resolve.test.ts index b1cfc41..b622913 100644 --- a/src/__tests__/cli-routing-issue-resolve.test.ts +++ b/src/__tests__/cli-routing-issue-resolve.test.ts @@ -48,7 +48,8 @@ vi.mock('../features/interactive/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', () => ({ diff --git a/src/__tests__/formatMovementPreviews.test.ts b/src/__tests__/formatMovementPreviews.test.ts new file mode 100644 index 0000000..4cdf66b --- /dev/null +++ b/src/__tests__/formatMovementPreviews.test.ts @@ -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); + }); +}); diff --git a/src/__tests__/globalConfig-defaults.test.ts b/src/__tests__/globalConfig-defaults.test.ts index 5cc39ff..484c419 100644 --- a/src/__tests__/globalConfig-defaults.test.ts +++ b/src/__tests__/globalConfig-defaults.test.ts @@ -287,6 +287,55 @@ describe('loadGlobalConfig', () => { 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', () => { it('should throw when provider is codex but model is a Claude alias (opus)', () => { const taktDir = join(testHomeDir, '.takt'); diff --git a/src/__tests__/pieceResolver.test.ts b/src/__tests__/pieceResolver.test.ts index 8436d3b..c58da23 100644 --- a/src/__tests__/pieceResolver.test.ts +++ b/src/__tests__/pieceResolver.test.ts @@ -1,9 +1,9 @@ /** - * Tests for getPieceDescription and buildWorkflowString + * Tests for getPieceDescription, buildWorkflowString, and buildMovementPreviews */ 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 { tmpdir } from 'node:os'; import { getPieceDescription } from '../infra/config/loaders/pieceResolver.js'; @@ -49,6 +49,7 @@ movements: expect(result.pieceStructure).toBe( '1. plan (タスク計画)\n2. implement (実装)\n3. review' ); + expect(result.movementPreviews).toEqual([]); }); it('should return workflow structure with parallel movements', () => { @@ -91,6 +92,7 @@ movements: ' - arch_review\n' + '3. fix (修正)' ); + expect(result.movementPreviews).toEqual([]); }); it('should handle movements without descriptions', () => { @@ -115,6 +117,7 @@ movements: expect(result.name).toBe('minimal'); expect(result.description).toBe(''); expect(result.pieceStructure).toBe('1. step1\n2. step2'); + expect(result.movementPreviews).toEqual([]); }); it('should return empty strings when piece is not found', () => { @@ -123,6 +126,7 @@ movements: expect(result.name).toBe('nonexistent'); expect(result.description).toBe(''); expect(result.pieceStructure).toBe(''); + expect(result.movementPreviews).toEqual([]); }); it('should handle parallel movements without descriptions', () => { @@ -151,5 +155,411 @@ movements: ' - child1\n' + ' - 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'); }); }); diff --git a/src/app/cli/routing.ts b/src/app/cli/routing.ts index 7480fe9..279be15 100644 --- a/src/app/cli/routing.ts +++ b/src/app/cli/routing.ts @@ -11,7 +11,7 @@ import { fetchIssue, formatIssueAsTask, checkGhCli, parseIssueNumbers, type GitH import { selectAndExecuteTask, determinePiece, saveTaskFromInteractive, createIssueFromTask, type SelectAndExecuteOptions } from '../../features/tasks/index.js'; import { executePipeline } from '../../features/pipeline/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 { program, resolvedCwd, pipelineMode } from './program.js'; import { resolveAgentOverrides, parseCreateWorktreeOption, isDirectTask } from './helpers.js'; @@ -124,7 +124,9 @@ export async function executeDefaultAction(task?: string): Promise { 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); switch (result.action) { diff --git a/src/core/models/global-config.ts b/src/core/models/global-config.ts index 9375234..4ca9f75 100644 --- a/src/core/models/global-config.ts +++ b/src/core/models/global-config.ts @@ -67,6 +67,8 @@ export interface GlobalConfig { preventSleep?: boolean; /** Enable notification sounds (default: true when undefined) */ 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) */ concurrency: number; } diff --git a/src/core/models/schemas.ts b/src/core/models/schemas.ts index 8e50eb0..7ca1f7f 100644 --- a/src/core/models/schemas.ts +++ b/src/core/models/schemas.ts @@ -318,6 +318,8 @@ export const GlobalConfigSchema = z.object({ prevent_sleep: z.boolean().optional(), /** Enable notification sounds (default: true when undefined) */ 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) */ concurrency: z.number().int().min(1).max(10).optional().default(1), }); diff --git a/src/features/interactive/interactive.ts b/src/features/interactive/interactive.ts index 55bcd54..5afe470 100644 --- a/src/features/interactive/interactive.ts +++ b/src/features/interactive/interactive.ts @@ -19,6 +19,7 @@ import { loadSessionState, clearSessionState, type SessionState, + type MovementPreview, } from '../../infra/config/index.js'; import { isQuietMode } from '../../shared/context.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'; } +/** + * 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) { - 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, {}); return { @@ -149,10 +186,15 @@ function buildSummaryPrompt( } 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, }); } @@ -220,6 +262,8 @@ export interface PieceContext { description: string; /** Piece structure (numbered list of movements) */ pieceStructure: string; + /** Movement previews (persona + instruction content for first N movements) */ + movementPreviews?: MovementPreview[]; } /** diff --git a/src/features/tasks/add/index.ts b/src/features/tasks/add/index.ts index 3b92c2a..6da95e3 100644 --- a/src/features/tasks/add/index.ts +++ b/src/features/tasks/add/index.ts @@ -11,7 +11,7 @@ import { stringify as stringifyYaml } from 'yaml'; import { promptInput, confirm } from '../../../shared/prompt/index.js'; import { success, info, error } from '../../../shared/ui/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 { createLogger, getErrorMessage } from '../../../shared/utils/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 { } 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 const result = await interactiveMode(cwd, undefined, pieceContext); diff --git a/src/infra/config/global/globalConfig.ts b/src/infra/config/global/globalConfig.ts index 4509853..1f8aa00 100644 --- a/src/infra/config/global/globalConfig.ts +++ b/src/infra/config/global/globalConfig.ts @@ -35,6 +35,7 @@ function createDefaultGlobalConfig(): GlobalConfig { logLevel: 'info', provider: 'claude', enableBuiltinPieces: true, + interactivePreviewMovements: 3, concurrency: 1, }; } @@ -107,6 +108,7 @@ export class GlobalConfigManager { branchNameStrategy: parsed.branch_name_strategy, preventSleep: parsed.prevent_sleep, notificationSound: parsed.notification_sound, + interactivePreviewMovements: parsed.interactive_preview_movements, concurrency: parsed.concurrency, }; validateProviderModelCompatibility(config.provider, config.model); @@ -177,6 +179,9 @@ export class GlobalConfigManager { if (config.notificationSound !== undefined) { raw.notification_sound = config.notificationSound; } + if (config.interactivePreviewMovements !== undefined) { + raw.interactive_preview_movements = config.interactivePreviewMovements; + } if (config.concurrency !== undefined && config.concurrency > 1) { raw.concurrency = config.concurrency; } diff --git a/src/infra/config/loaders/index.ts b/src/infra/config/loaders/index.ts index b8b7f66..4bd9f54 100644 --- a/src/infra/config/loaders/index.ts +++ b/src/infra/config/loaders/index.ts @@ -12,6 +12,7 @@ export { loadAllPiecesWithSources, listPieces, listPieceEntries, + type MovementPreview, type PieceDirEntry, type PieceSource, type PieceWithSource, diff --git a/src/infra/config/loaders/pieceLoader.ts b/src/infra/config/loaders/pieceLoader.ts index 278bdc9..115adae 100644 --- a/src/infra/config/loaders/pieceLoader.ts +++ b/src/infra/config/loaders/pieceLoader.ts @@ -20,6 +20,7 @@ export { loadAllPiecesWithSources, listPieces, listPieceEntries, + type MovementPreview, type PieceDirEntry, type PieceSource, type PieceWithSource, diff --git a/src/infra/config/loaders/pieceResolver.ts b/src/infra/config/loaders/pieceResolver.ts index 32c3296..7709969 100644 --- a/src/infra/config/loaders/pieceResolver.ts +++ b/src/infra/config/loaders/pieceResolver.ts @@ -5,7 +5,7 @@ * 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 { homedir } from 'node:os'; import type { PieceConfig, PieceMovement } from '../../../core/models/index.js'; @@ -176,22 +176,100 @@ function buildWorkflowString(movements: PieceMovement[]): string { 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(); + for (const m of piece.movements) { + movementMap.set(m.name, m); + } + + const previews: MovementPreview[] = []; + const visited = new Set(); + 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. - * Returns the piece name, description, and workflow structure. + * Returns the piece name, description, workflow structure, and optional movement previews. */ export function getPieceDescription( identifier: string, projectCwd: string, -): { name: string; description: string; pieceStructure: string } { + previewCount?: number, +): { name: string; description: string; pieceStructure: string; movementPreviews: MovementPreview[] } { const piece = loadPieceByIdentifier(identifier, projectCwd); if (!piece) { - return { name: identifier, description: '', pieceStructure: '' }; + return { name: identifier, description: '', pieceStructure: '', movementPreviews: [] }; } return { name: piece.name, description: piece.description ?? '', pieceStructure: buildWorkflowString(piece.movements), + movementPreviews: previewCount && previewCount > 0 + ? buildMovementPreviews(piece, previewCount) + : [], }; } diff --git a/src/infra/config/loaders/resource-resolver.ts b/src/infra/config/loaders/resource-resolver.ts index 94a690a..99e250b 100644 --- a/src/infra/config/loaders/resource-resolver.ts +++ b/src/infra/config/loaders/resource-resolver.ts @@ -138,7 +138,8 @@ export function resolveRefToContent( } if (facetType && context) { - return resolveFacetByName(ref, facetType, context); + const facetContent = resolveFacetByName(ref, facetType, context); + if (facetContent !== undefined) return facetContent; } return resolveResourceContent(ref, pieceDir); diff --git a/src/shared/prompts/en/score_interactive_policy.md b/src/shared/prompts/en/score_interactive_policy.md index 0f74615..284c354 100644 --- a/src/shared/prompts/en/score_interactive_policy.md +++ b/src/shared/prompts/en/score_interactive_policy.md @@ -13,7 +13,7 @@ Focus on creating task instructions for the piece. Do not execute tasks or inves | Principle | Standard | |-----------|----------| | 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 | ## 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 -### When Investigation IS Appropriate (Rare) +### When Investigation IS Appropriate -Only when the user explicitly asks YOU to investigate: -- "Read the README to understand the project structure" -- "Read file X to see what it does" -- "What does this project do?" +When it improves instruction quality: +- Verifying file or module existence (narrowing targets) +- Understanding project structure (improving instruction accuracy) +- 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: -- "Review the changes" → Create instructions without investigating -- "Fix the code" → Create instructions without investigating -- "Implement X" → Create instructions without investigating +When agents can investigate on their own: +- Implementation details (code internals, dependency analysis) +- Determining how to make changes +- Running tests or builds ## Strict Requirements diff --git a/src/shared/prompts/en/score_interactive_system_prompt.md b/src/shared/prompts/en/score_interactive_system_prompt.md index 649e9a6..a7995d7 100644 --- a/src/shared/prompts/en/score_interactive_system_prompt.md +++ b/src/shared/prompts/en/score_interactive_system_prompt.md @@ -1,7 +1,7 @@ # Interactive Mode Assistant @@ -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) - Execute tasks (piece's job) - 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}} diff --git a/src/shared/prompts/en/score_summary_system_prompt.md b/src/shared/prompts/en/score_summary_system_prompt.md index 74864e2..d6def6e 100644 --- a/src/shared/prompts/en/score_summary_system_prompt.md +++ b/src/shared/prompts/en/score_summary_system_prompt.md @@ -1,7 +1,7 @@ You are a task summarizer. Convert the conversation into a concrete task instruction for the planning step. @@ -18,6 +18,7 @@ Requirements: ## Destination of Your Task Instruction This task instruction will be passed to the "{{pieceName}}" piece. Piece description: {{pieceDescription}} +{{movementDetails}} Create the instruction in the format expected by this piece. {{/if}} diff --git a/src/shared/prompts/ja/score_interactive_policy.md b/src/shared/prompts/ja/score_interactive_policy.md index 35bded3..f14ce84 100644 --- a/src/shared/prompts/ja/score_interactive_policy.md +++ b/src/shared/prompts/ja/score_interactive_policy.md @@ -13,7 +13,7 @@ | 原則 | 基準 | |------|------| | 指示書作成に専念 | タスク実行は常にピースの仕事 | -| 調査の抑制 | 明示的な依頼がない限り調査しない | +| スマートな委譲 | エージェントが調査できる内容は委ねる | | 簡潔な返答 | 要点のみ。冗長な説明を避ける | ## ユーザーの意図の理解 @@ -28,19 +28,19 @@ ## 調査の判断基準 -### 調査してよい場合(稀) +### 調査してよい場合 -ユーザーが明示的に「あなた」に調査を依頼した場合のみ: -- 「READMEを読んでプロジェクト構造を理解して」 -- 「ファイルXを読んで何をしているか見て」 -- 「このプロジェクトは何をするもの?」 +指示書の質を上げるために有益な場合: +- ファイルやモジュールの存在確認(対象の絞り込み) +- プロジェクト構造の把握(指示書の精度向上) +- ユーザーが明示的に調査を依頼した場合 -### 調査してはいけない場合(ほとんど) +### 調査しない場合 -ユーザーがピース向けのタスクを説明している場合: -- 「変更をレビューして」→ 調査せずに指示書を作成 -- 「コードを修正して」→ 調査せずに指示書を作成 -- 「Xを実装して」→ 調査せずに指示書を作成 +エージェントが自分で調査できる内容: +- 実装の詳細(コードの中身、依存関係の解析) +- 変更方法の特定(どう修正するか) +- テスト・ビルドの実行 ## 厳守事項 diff --git a/src/shared/prompts/ja/score_interactive_system_prompt.md b/src/shared/prompts/ja/score_interactive_system_prompt.md index 25ee76f..5b2169b 100644 --- a/src/shared/prompts/ja/score_interactive_system_prompt.md +++ b/src/shared/prompts/ja/score_interactive_system_prompt.md @@ -1,7 +1,7 @@ # 対話モードアシスタント @@ -24,3 +24,22 @@ TAKTの対話モードを担当し、ユーザーと会話してピース実行 - コードベース調査、前提把握、対象ファイル特定(ピースの仕事) - タスクの実行(ピースの仕事) - スラッシュコマンドへの言及 +{{#if hasPiecePreview}} + +## ピース構成 + +このタスクは以下のワークフローで処理されます: +{{pieceStructure}} + +### エージェント詳細 + +以下のエージェントが順次タスクを処理します。各エージェントの能力と指示内容を理解し、指示書の質を高めてください。 + +{{movementDetails}} + +### 委譲ガイダンス + +- 上記エージェントが自ら調査・判断できる内容は、指示書に過度な詳細を含める必要はありません +- エージェントが自力で解決できない情報(ユーザーの意図、優先度、制約条件など)を指示書に明確に含めてください +- コードベースの調査、実装詳細の特定、依存関係の解析はエージェントに委ねてください +{{/if}} diff --git a/src/shared/prompts/ja/score_summary_system_prompt.md b/src/shared/prompts/ja/score_summary_system_prompt.md index 4113744..236d0e8 100644 --- a/src/shared/prompts/ja/score_summary_system_prompt.md +++ b/src/shared/prompts/ja/score_summary_system_prompt.md @@ -1,7 +1,7 @@ あなたはTAKTの対話モードを担当しています。これまでの会話内容を、ピース実行用の具体的なタスク指示書に変換してください。 @@ -25,6 +25,7 @@ ## あなたが作成する指示書の行き先 このタスク指示書は「{{pieceName}}」ピースに渡されます。 ピースの内容: {{pieceDescription}} +{{movementDetails}} 指示書は、このピースが期待する形式で作成してください。 {{/if}}