diff --git a/src/__tests__/addTask.test.ts b/src/__tests__/addTask.test.ts index f936c57..e6f74c7 100644 --- a/src/__tests__/addTask.test.ts +++ b/src/__tests__/addTask.test.ts @@ -50,7 +50,11 @@ vi.mock('../features/tasks/execute/selectAndExecute.js', () => ({ })); vi.mock('../infra/config/loaders/pieceResolver.js', () => ({ - getPieceDescription: vi.fn(() => ({ name: 'default', description: '' })), + getPieceDescription: vi.fn(() => ({ + name: 'default', + description: '', + pieceStructure: '1. implement\n2. review' + })), })); vi.mock('../infra/github/issue.js', () => ({ @@ -92,7 +96,7 @@ function setupFullFlowMocks(overrides?: { const slug = overrides?.slug ?? 'add-auth'; mockDeterminePiece.mockResolvedValue('default'); - mockGetPieceDescription.mockReturnValue({ name: 'default', description: '' }); + mockGetPieceDescription.mockReturnValue({ name: 'default', description: '', pieceStructure: '' }); mockInteractiveMode.mockResolvedValue({ confirmed: true, task }); mockSummarizeTaskName.mockResolvedValue(slug); mockConfirm.mockResolvedValue(false); @@ -104,7 +108,7 @@ beforeEach(() => { vi.clearAllMocks(); testDir = fs.mkdtempSync(path.join(tmpdir(), 'takt-test-')); mockDeterminePiece.mockResolvedValue('default'); - mockGetPieceDescription.mockReturnValue({ name: 'default', description: '' }); + mockGetPieceDescription.mockReturnValue({ name: 'default', description: '', pieceStructure: '' }); mockConfirm.mockResolvedValue(false); }); @@ -225,7 +229,7 @@ describe('addTask', () => { // Given: determinePiece returns a non-default piece setupFullFlowMocks({ slug: 'with-piece' }); mockDeterminePiece.mockResolvedValue('review'); - mockGetPieceDescription.mockReturnValue({ name: 'review', description: 'Code review piece' }); + mockGetPieceDescription.mockReturnValue({ name: 'review', description: 'Code review piece', pieceStructure: '' }); mockConfirm.mockResolvedValue(false); // When diff --git a/src/__tests__/instructionBuilder.test.ts b/src/__tests__/instructionBuilder.test.ts index f96d98f..c19b809 100644 --- a/src/__tests__/instructionBuilder.test.ts +++ b/src/__tests__/instructionBuilder.test.ts @@ -46,6 +46,8 @@ function createMinimalContext(overrides: Partial = {}): Inst cwd: '/project', projectCwd: '/project', userInputs: [], + pieceName: 'test-piece', + pieceDescription: 'Test piece description', ...overrides, }; } @@ -299,6 +301,62 @@ describe('instruction-builder', () => { }); describe('auto-injected Piece Context section', () => { + it('should include piece name when provided', () => { + const step = createMinimalStep('Do work'); + const context = createMinimalContext({ + pieceName: 'my-piece', + language: 'en', + }); + + const result = buildInstruction(step, context); + + expect(result).toContain('## Piece Context'); + expect(result).toContain('- Piece: my-piece'); + }); + + it('should include piece description when provided', () => { + const step = createMinimalStep('Do work'); + const context = createMinimalContext({ + pieceName: 'my-piece', + pieceDescription: 'A test piece for validation', + language: 'en', + }); + + const result = buildInstruction(step, context); + + expect(result).toContain('## Piece Context'); + expect(result).toContain('- Piece: my-piece'); + expect(result).toContain('- Description: A test piece for validation'); + }); + + it('should not show description when not provided', () => { + const step = createMinimalStep('Do work'); + const context = createMinimalContext({ + pieceName: 'my-piece', + pieceDescription: undefined, + language: 'en', + }); + + const result = buildInstruction(step, context); + + expect(result).toContain('- Piece: my-piece'); + expect(result).not.toContain('- Description:'); + }); + + it('should render piece context in Japanese', () => { + const step = createMinimalStep('Do work'); + const context = createMinimalContext({ + pieceName: 'coding', + pieceDescription: 'コーディングピース', + language: 'ja', + }); + + const result = buildInstruction(step, context); + + expect(result).toContain('- ピース: coding'); + expect(result).toContain('- 説明: コーディングピース'); + }); + it('should include iteration, step iteration, and step name', () => { const step = createMinimalStep('Do work'); step.name = 'implement'; diff --git a/src/__tests__/interactive.test.ts b/src/__tests__/interactive.test.ts index 66137c4..fba65ce 100644 --- a/src/__tests__/interactive.test.ts +++ b/src/__tests__/interactive.test.ts @@ -31,6 +31,8 @@ vi.mock('../infra/config/paths.js', async (importOriginal) => ({ loadAgentSessions: vi.fn(() => ({})), updateAgentSession: vi.fn(), getProjectConfigDir: vi.fn(() => '/tmp'), + loadSessionState: vi.fn(() => null), + clearSessionState: vi.fn(), })); vi.mock('../shared/ui/index.js', () => ({ @@ -279,4 +281,59 @@ describe('interactiveMode', () => { expect(result.confirmed).toBe(true); expect(result.task).toBe('Fix login page with clarified scope.'); }); + + describe('/play command', () => { + it('should return confirmed=true with task on /play command', async () => { + // Given + setupInputSequence(['/play implement login feature']); + setupMockProvider([]); + + // When + const result = await interactiveMode('/project'); + + // Then + expect(result.confirmed).toBe(true); + expect(result.task).toBe('implement login feature'); + }); + + it('should show error when /play has no task content', async () => { + // Given: /play without task, then /cancel to exit + setupInputSequence(['/play', '/cancel']); + setupMockProvider([]); + + // When + const result = await interactiveMode('/project'); + + // Then: should not confirm (fell through to /cancel) + expect(result.confirmed).toBe(false); + }); + + it('should handle /play with leading/trailing spaces', async () => { + // Given + setupInputSequence(['/play test task ']); + setupMockProvider([]); + + // When + const result = await interactiveMode('/project'); + + // Then + expect(result.confirmed).toBe(true); + expect(result.task).toBe('test task'); + }); + + it('should skip AI summary when using /play', async () => { + // Given + setupInputSequence(['/play quick task']); + setupMockProvider([]); + + // When + const result = await interactiveMode('/project'); + + // Then: provider should NOT have been called (no summary needed) + const mockProvider = mockGetProvider.mock.results[0]?.value as { call: ReturnType }; + expect(mockProvider.call).not.toHaveBeenCalled(); + expect(result.confirmed).toBe(true); + expect(result.task).toBe('quick task'); + }); + }); }); diff --git a/src/__tests__/pieceResolver.test.ts b/src/__tests__/pieceResolver.test.ts new file mode 100644 index 0000000..25752ac --- /dev/null +++ b/src/__tests__/pieceResolver.test.ts @@ -0,0 +1,155 @@ +/** + * Tests for getPieceDescription and buildWorkflowString + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdtempSync, writeFileSync, mkdirSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { getPieceDescription } from '../infra/config/loaders/pieceResolver.js'; + +describe('getPieceDescription', () => { + let tempDir: string; + + beforeEach(() => { + tempDir = mkdtempSync(join(tmpdir(), 'takt-test-piece-resolver-')); + }); + + afterEach(() => { + rmSync(tempDir, { recursive: true, force: true }); + }); + + it('should return workflow structure with sequential movements', () => { + const pieceYaml = `name: test-piece +description: Test piece for workflow +initial_movement: plan +max_iterations: 3 + +movements: + - name: plan + description: タスク計画 + agent: planner + instruction: "Plan the task" + - name: implement + description: 実装 + agent: coder + instruction: "Implement" + - name: review + agent: reviewer + instruction: "Review" +`; + + const piecePath = join(tempDir, 'test.yaml'); + writeFileSync(piecePath, pieceYaml); + + const result = getPieceDescription(piecePath, tempDir); + + expect(result.name).toBe('test-piece'); + expect(result.description).toBe('Test piece for workflow'); + expect(result.pieceStructure).toBe( + '1. plan (タスク計画)\n2. implement (実装)\n3. review' + ); + }); + + it('should return workflow structure with parallel movements', () => { + const pieceYaml = `name: coding +description: Full coding workflow +initial_movement: plan +max_iterations: 10 + +movements: + - name: plan + description: タスク計画 + agent: planner + instruction: "Plan" + - name: reviewers + description: 並列レビュー + parallel: + - name: ai_review + agent: ai-reviewer + instruction: "AI review" + - name: arch_review + agent: arch-reviewer + instruction: "Architecture review" + - name: fix + description: 修正 + agent: coder + instruction: "Fix" +`; + + const piecePath = join(tempDir, 'coding.yaml'); + writeFileSync(piecePath, pieceYaml); + + const result = getPieceDescription(piecePath, tempDir); + + expect(result.name).toBe('coding'); + expect(result.description).toBe('Full coding workflow'); + expect(result.pieceStructure).toBe( + '1. plan (タスク計画)\n' + + '2. reviewers (並列レビュー)\n' + + ' - ai_review\n' + + ' - arch_review\n' + + '3. fix (修正)' + ); + }); + + it('should handle movements without descriptions', () => { + const pieceYaml = `name: minimal +initial_movement: step1 +max_iterations: 1 + +movements: + - name: step1 + agent: coder + instruction: "Do step1" + - name: step2 + agent: coder + instruction: "Do step2" +`; + + const piecePath = join(tempDir, 'minimal.yaml'); + writeFileSync(piecePath, pieceYaml); + + const result = getPieceDescription(piecePath, tempDir); + + expect(result.name).toBe('minimal'); + expect(result.description).toBe(''); + expect(result.pieceStructure).toBe('1. step1\n2. step2'); + }); + + it('should return empty strings when piece is not found', () => { + const result = getPieceDescription('nonexistent', tempDir); + + expect(result.name).toBe('nonexistent'); + expect(result.description).toBe(''); + expect(result.pieceStructure).toBe(''); + }); + + it('should handle parallel movements without descriptions', () => { + const pieceYaml = `name: test-parallel +initial_movement: parent +max_iterations: 1 + +movements: + - name: parent + parallel: + - name: child1 + agent: agent1 + instruction: "Do child1" + - name: child2 + agent: agent2 + instruction: "Do child2" +`; + + const piecePath = join(tempDir, 'test-parallel.yaml'); + writeFileSync(piecePath, pieceYaml); + + const result = getPieceDescription(piecePath, tempDir); + + expect(result.pieceStructure).toBe( + '1. parent\n' + + ' - child1\n' + + ' - child2' + ); + }); +}); diff --git a/src/__tests__/prompts.test.ts b/src/__tests__/prompts.test.ts index 22328be..7fa0578 100644 --- a/src/__tests__/prompts.test.ts +++ b/src/__tests__/prompts.test.ts @@ -57,14 +57,15 @@ describe('variable substitution', () => { expect(result).toContain('| 1 | Success |'); }); - it('replaces piece info variables in interactive prompt', () => { + it('interactive prompt does not contain piece info', () => { const result = loadTemplate('score_interactive_system_prompt', 'en', { pieceInfo: true, pieceName: 'my-piece', pieceDescription: 'Test description', }); - expect(result).toContain('"my-piece"'); - expect(result).toContain('Test description'); + // ピース情報はインタラクティブプロンプトには含まれない(要約プロンプトにのみ含まれる) + expect(result).not.toContain('"my-piece"'); + expect(result).not.toContain('Test description'); }); }); diff --git a/src/__tests__/sessionState.test.ts b/src/__tests__/sessionState.test.ts new file mode 100644 index 0000000..d418ac2 --- /dev/null +++ b/src/__tests__/sessionState.test.ts @@ -0,0 +1,186 @@ +/** + * Session state management tests + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdirSync, rmSync, existsSync } from 'node:fs'; +import { join } from 'node:path'; +import { + loadSessionState, + saveSessionState, + clearSessionState, + getSessionStatePath, + type SessionState, +} from '../infra/config/project/sessionState.js'; + +describe('sessionState', () => { + const testDir = join(__dirname, '__temp_session_state_test__'); + + beforeEach(() => { + if (existsSync(testDir)) { + rmSync(testDir, { recursive: true, force: true }); + } + mkdirSync(testDir, { recursive: true }); + }); + + afterEach(() => { + if (existsSync(testDir)) { + rmSync(testDir, { recursive: true, force: true }); + } + }); + + describe('getSessionStatePath', () => { + it('should return correct path', () => { + const path = getSessionStatePath(testDir); + expect(path).toContain('.takt'); + expect(path).toContain('session-state.json'); + }); + }); + + describe('loadSessionState', () => { + it('should return null when file does not exist', () => { + const state = loadSessionState(testDir); + expect(state).toBeNull(); + }); + + it('should load saved state', () => { + const savedState: SessionState = { + status: 'success', + taskResult: 'Task completed successfully', + timestamp: new Date().toISOString(), + pieceName: 'coding', + taskContent: 'Implement feature X', + lastMovement: 'implement', + }; + + saveSessionState(testDir, savedState); + const loadedState = loadSessionState(testDir); + + expect(loadedState).toEqual(savedState); + }); + + it('should return null when JSON parsing fails', () => { + const path = getSessionStatePath(testDir); + const configDir = join(testDir, '.takt'); + mkdirSync(configDir, { recursive: true }); + + // Write invalid JSON + const fs = require('node:fs'); + fs.writeFileSync(path, 'invalid json', 'utf-8'); + + const state = loadSessionState(testDir); + expect(state).toBeNull(); + }); + }); + + describe('saveSessionState', () => { + it('should save state correctly', () => { + const state: SessionState = { + status: 'success', + taskResult: 'Task completed', + timestamp: new Date().toISOString(), + pieceName: 'minimal', + taskContent: 'Test task', + lastMovement: 'test-movement', + }; + + saveSessionState(testDir, state); + + const path = getSessionStatePath(testDir); + expect(existsSync(path)).toBe(true); + + const loaded = loadSessionState(testDir); + expect(loaded).toEqual(state); + }); + + it('should save error state', () => { + const state: SessionState = { + status: 'error', + errorMessage: 'Something went wrong', + timestamp: new Date().toISOString(), + pieceName: 'coding', + taskContent: 'Failed task', + }; + + saveSessionState(testDir, state); + const loaded = loadSessionState(testDir); + + expect(loaded).toEqual(state); + }); + + it('should save user_stopped state', () => { + const state: SessionState = { + status: 'user_stopped', + timestamp: new Date().toISOString(), + pieceName: 'coding', + taskContent: 'Interrupted task', + }; + + saveSessionState(testDir, state); + const loaded = loadSessionState(testDir); + + expect(loaded).toEqual(state); + }); + }); + + describe('clearSessionState', () => { + it('should delete state file', () => { + const state: SessionState = { + status: 'success', + timestamp: new Date().toISOString(), + pieceName: 'coding', + }; + + saveSessionState(testDir, state); + const path = getSessionStatePath(testDir); + expect(existsSync(path)).toBe(true); + + clearSessionState(testDir); + expect(existsSync(path)).toBe(false); + }); + + it('should not throw when file does not exist', () => { + expect(() => clearSessionState(testDir)).not.toThrow(); + }); + }); + + describe('integration', () => { + it('should support one-time notification pattern', () => { + // Save state + const state: SessionState = { + status: 'success', + taskResult: 'Done', + timestamp: new Date().toISOString(), + pieceName: 'coding', + }; + saveSessionState(testDir, state); + + // Load once + const loaded1 = loadSessionState(testDir); + expect(loaded1).toEqual(state); + + // Clear immediately + clearSessionState(testDir); + + // Load again - should be null + const loaded2 = loadSessionState(testDir); + expect(loaded2).toBeNull(); + }); + + it('should handle truncated strings', () => { + const longString = 'a'.repeat(2000); + const state: SessionState = { + status: 'success', + taskResult: longString, + timestamp: new Date().toISOString(), + pieceName: 'coding', + taskContent: longString, + }; + + saveSessionState(testDir, state); + const loaded = loadSessionState(testDir); + + expect(loaded).toEqual(state); + }); + }); +}); diff --git a/src/agents/runner.ts b/src/agents/runner.ts index a27d74a..bd50f77 100644 --- a/src/agents/runner.ts +++ b/src/agents/runner.ts @@ -196,7 +196,20 @@ export class AgentRunner { if (options.agentPath) { const agentDefinition = AgentRunner.loadAgentPromptFromPath(options.agentPath); const language = options.language ?? 'en'; - const systemPrompt = loadTemplate('perform_agent_system_prompt', language, { agentDefinition }); + const templateVars: Record = { agentDefinition }; + + // Add piece meta information if available + if (options.pieceMeta) { + templateVars.pieceName = options.pieceMeta.pieceName; + templateVars.pieceDescription = options.pieceMeta.pieceDescription ?? ''; + templateVars.currentMovement = options.pieceMeta.currentMovement; + templateVars.movementsList = options.pieceMeta.movementsList + .map((m, i) => `${i + 1}. ${m.name}${m.description ? ` - ${m.description}` : ''}`) + .join('\n'); + templateVars.currentPosition = options.pieceMeta.currentPosition; + } + + const systemPrompt = loadTemplate('perform_agent_system_prompt', language, templateVars); const providerType = AgentRunner.resolveProvider(options.cwd, options); const provider = getProvider(providerType); return provider.call(agentName, task, AgentRunner.buildProviderCallOptions(options, systemPrompt)); diff --git a/src/agents/types.ts b/src/agents/types.ts index 8bf7933..fbfa74d 100644 --- a/src/agents/types.ts +++ b/src/agents/types.ts @@ -28,4 +28,12 @@ export interface RunAgentOptions { bypassPermissions?: boolean; /** Language for template resolution */ language?: Language; + /** Piece meta information for system prompt template */ + pieceMeta?: { + pieceName: string; + pieceDescription?: string; + currentMovement: string; + movementsList: ReadonlyArray<{ name: string; description?: string }>; + currentPosition: string; + }; } diff --git a/src/core/piece/engine/MovementExecutor.ts b/src/core/piece/engine/MovementExecutor.ts index 29f731b..f42c54b 100644 --- a/src/core/piece/engine/MovementExecutor.ts +++ b/src/core/piece/engine/MovementExecutor.ts @@ -33,6 +33,8 @@ export interface MovementExecutorDeps { readonly getLanguage: () => Language | undefined; readonly getInteractive: () => boolean; readonly getPieceMovements: () => ReadonlyArray<{ name: string; description?: string }>; + readonly getPieceName: () => string; + readonly getPieceDescription: () => string | undefined; readonly detectRuleIndex: (content: string, movementName: string) => number; readonly callAiJudge: ( agentOutput: string, @@ -71,6 +73,8 @@ export class MovementExecutor { interactive: this.deps.getInteractive(), pieceMovements: pieceMovements, currentMovementIndex: pieceMovements.findIndex(s => s.name === step.name), + pieceName: this.deps.getPieceName(), + pieceDescription: this.deps.getPieceDescription(), }).build(); } diff --git a/src/core/piece/engine/OptionsBuilder.ts b/src/core/piece/engine/OptionsBuilder.ts index d01ecfc..2ae892a 100644 --- a/src/core/piece/engine/OptionsBuilder.ts +++ b/src/core/piece/engine/OptionsBuilder.ts @@ -19,10 +19,17 @@ export class OptionsBuilder { private readonly getSessionId: (agent: string) => string | undefined, private readonly getReportDir: () => string, private readonly getLanguage: () => Language | undefined, + private readonly getPieceMovements: () => ReadonlyArray<{ name: string; description?: string }>, + private readonly getPieceName: () => string, + private readonly getPieceDescription: () => string | undefined, ) {} /** Build common RunAgentOptions shared by all phases */ buildBaseOptions(step: PieceMovement): RunAgentOptions { + const movements = this.getPieceMovements(); + const currentIndex = movements.findIndex((m) => m.name === step.name); + const currentPosition = currentIndex >= 0 ? `${currentIndex + 1}/${movements.length}` : '?/?'; + return { cwd: this.getCwd(), agentPath: step.agentPath, @@ -34,6 +41,13 @@ export class OptionsBuilder { onPermissionRequest: this.engineOptions.onPermissionRequest, onAskUserQuestion: this.engineOptions.onAskUserQuestion, bypassPermissions: this.engineOptions.bypassPermissions, + pieceMeta: { + pieceName: this.getPieceName(), + pieceDescription: this.getPieceDescription(), + currentMovement: step.name, + movementsList: movements, + currentPosition, + }, }; } diff --git a/src/core/piece/engine/PieceEngine.ts b/src/core/piece/engine/PieceEngine.ts index 05477ec..bfe4921 100644 --- a/src/core/piece/engine/PieceEngine.ts +++ b/src/core/piece/engine/PieceEngine.ts @@ -91,6 +91,9 @@ export class PieceEngine extends EventEmitter { (agent) => this.state.agentSessions.get(agent), () => this.reportDir, () => this.options.language, + () => this.config.movements.map(s => ({ name: s.name, description: s.description })), + () => this.getPieceName(), + () => this.getPieceDescription(), ); this.movementExecutor = new MovementExecutor({ @@ -101,6 +104,8 @@ export class PieceEngine extends EventEmitter { getLanguage: () => this.options.language, getInteractive: () => this.options.interactive === true, getPieceMovements: () => this.config.movements.map(s => ({ name: s.name, description: s.description })), + getPieceName: () => this.getPieceName(), + getPieceDescription: () => this.getPieceDescription(), detectRuleIndex: this.detectRuleIndex, callAiJudge: this.callAiJudge, onPhaseStart: (step, phase, phaseName, instruction) => { @@ -205,6 +210,16 @@ export class PieceEngine extends EventEmitter { return this.projectCwd; } + /** Get piece name */ + private getPieceName(): string { + return this.config.name; + } + + /** Get piece description */ + private getPieceDescription(): string | undefined { + return this.config.description; + } + /** Request graceful abort: interrupt running queries and stop after current movement */ abort(): void { if (this.abortRequested) return; diff --git a/src/core/piece/instruction/InstructionBuilder.ts b/src/core/piece/instruction/InstructionBuilder.ts index b570539..5773f01 100644 --- a/src/core/piece/instruction/InstructionBuilder.ts +++ b/src/core/piece/instruction/InstructionBuilder.ts @@ -89,9 +89,17 @@ export class InstructionBuilder { this.context, ); + // Piece name and description + const pieceName = this.context.pieceName ?? ''; + const pieceDescription = this.context.pieceDescription ?? ''; + const hasPieceDescription = !!pieceDescription; + return loadTemplate('perform_phase1_message', language, { workingDirectory: this.context.cwd, editRule, + pieceName, + pieceDescription, + hasPieceDescription, pieceStructure, iteration: `${this.context.iteration}/${this.context.maxIterations}`, movementIteration: String(this.context.movementIteration), diff --git a/src/core/piece/instruction/instruction-context.ts b/src/core/piece/instruction/instruction-context.ts index 761882e..c9e4cf4 100644 --- a/src/core/piece/instruction/instruction-context.ts +++ b/src/core/piece/instruction/instruction-context.ts @@ -36,6 +36,10 @@ export interface InstructionContext { pieceMovements?: ReadonlyArray<{ name: string; description?: string }>; /** Index of the current movement in pieceMovements (0-based) */ currentMovementIndex?: number; + /** Piece name */ + pieceName?: string; + /** Piece description (optional) */ + pieceDescription?: string; } /** diff --git a/src/features/interactive/interactive.ts b/src/features/interactive/interactive.ts index 850cb20..360238d 100644 --- a/src/features/interactive/interactive.ts +++ b/src/features/interactive/interactive.ts @@ -13,7 +13,14 @@ import * as readline from 'node:readline'; import chalk from 'chalk'; import type { Language } from '../../core/models/index.js'; -import { loadGlobalConfig, loadAgentSessions, updateAgentSession } from '../../infra/config/index.js'; +import { + loadGlobalConfig, + loadAgentSessions, + updateAgentSession, + loadSessionState, + clearSessionState, + type SessionState, +} from '../../infra/config/index.js'; import { isQuietMode } from '../../shared/context.js'; import { getProvider, type ProviderType } from '../../infra/providers/index.js'; import { selectOption } from '../../shared/prompt/index.js'; @@ -33,6 +40,44 @@ interface InteractiveUIText { proposed: string; confirm: string; cancelled: string; + playNoTask: string; +} + +/** + * Format session state for display + */ +function formatSessionStatus(state: SessionState, lang: 'en' | 'ja'): string { + const lines: string[] = []; + + // Status line + if (state.status === 'success') { + lines.push(getLabel('interactive.previousTask.success', lang)); + } else if (state.status === 'error') { + lines.push( + getLabel('interactive.previousTask.error', lang, { + error: state.errorMessage!, + }) + ); + } else if (state.status === 'user_stopped') { + lines.push(getLabel('interactive.previousTask.userStopped', lang)); + } + + // Piece name + lines.push( + getLabel('interactive.previousTask.piece', lang, { + pieceName: state.pieceName, + }) + ); + + // Timestamp + const timestamp = new Date(state.timestamp).toLocaleString(lang === 'ja' ? 'ja-JP' : 'en-US'); + lines.push( + getLabel('interactive.previousTask.timestamp', lang, { + timestamp, + }) + ); + + return lines.join('\n'); } function resolveLanguage(lang?: Language): 'en' | 'ja' { @@ -40,13 +85,7 @@ function resolveLanguage(lang?: Language): 'en' | 'ja' { } function getInteractivePrompts(lang: 'en' | 'ja', pieceContext?: PieceContext) { - const hasPiece = !!pieceContext; - - const systemPrompt = loadTemplate('score_interactive_system_prompt', lang, { - pieceInfo: hasPiece, - pieceName: pieceContext?.name ?? '', - pieceDescription: pieceContext?.description ?? '', - }); + const systemPrompt = loadTemplate('score_interactive_system_prompt', lang, {}); return { systemPrompt, @@ -194,6 +233,8 @@ export interface PieceContext { name: string; /** Piece description */ description: string; + /** Piece structure (numbered list of movements) */ + pieceStructure: string; } /** @@ -225,6 +266,15 @@ export async function interactiveMode( const savedSessions = loadAgentSessions(cwd, providerType); let sessionId: string | undefined = savedSessions[agentName]; + // Load and display previous task state + const sessionState = loadSessionState(cwd); + if (sessionState) { + const statusLabel = formatSessionStatus(sessionState, lang); + info(statusLabel); + blankLine(); + clearSessionState(cwd); + } + info(prompts.ui.intro); if (sessionId) { info(prompts.ui.resume); @@ -315,6 +365,16 @@ export async function interactiveMode( } // Handle slash commands + if (trimmed.startsWith('/play')) { + const task = trimmed.slice(5).trim(); + if (!task) { + info(prompts.ui.playNoTask); + continue; + } + log.info('Play command', { task }); + return { confirmed: true, task }; + } + if (trimmed.startsWith('/go')) { const userNote = trimmed.slice(3).trim(); let summaryPrompt = buildSummaryPrompt( diff --git a/src/features/tasks/execute/pieceExecution.ts b/src/features/tasks/execute/pieceExecution.ts index 7551c4c..0bc0ba6 100644 --- a/src/features/tasks/execute/pieceExecution.ts +++ b/src/features/tasks/execute/pieceExecution.ts @@ -16,6 +16,8 @@ import { loadWorktreeSessions, updateWorktreeSession, loadGlobalConfig, + saveSessionState, + type SessionState, } from '../../../infra/config/index.js'; import { isQuietMode } from '../../../shared/context.js'; import { @@ -51,6 +53,16 @@ import { getLabel } from '../../../shared/i18n/index.js'; const log = createLogger('piece'); +/** + * Truncate string to maximum length + */ +function truncate(str: string, maxLength: number): string { + if (str.length <= maxLength) { + return str; + } + return str.slice(0, maxLength) + '...'; +} + /** * Format elapsed time in human-readable format */ @@ -219,6 +231,8 @@ export async function executePiece( }); let abortReason: string | undefined; + let lastMovementContent: string | undefined; + let lastMovementName: string | undefined; engine.on('phase:start', (step, phase, phaseName, instruction) => { log.debug('Phase starting', { step: step.name, phase, phaseName }); @@ -283,6 +297,11 @@ export async function executePiece( sessionId: response.sessionId, error: response.error, }); + + // Capture last movement output for session state + lastMovementContent = response.content; + lastMovementName = step.name; + if (displayRef.current) { displayRef.current.flush(); displayRef.current = null; @@ -348,6 +367,21 @@ export async function executePiece( appendNdjsonLine(ndjsonLogPath, record); updateLatestPointer(sessionLog, pieceSessionId, projectCwd); + // Save session state for next interactive mode + try { + const sessionState: SessionState = { + status: 'success', + taskResult: truncate(lastMovementContent ?? '', 1000), + timestamp: new Date().toISOString(), + pieceName: pieceConfig.name, + taskContent: truncate(task, 200), + lastMovement: lastMovementName, + }; + saveSessionState(projectCwd, sessionState); + } catch (error) { + log.error('Failed to save session state', { error }); + } + const elapsed = sessionLog.endTime ? formatElapsedTime(sessionLog.startTime, sessionLog.endTime) : ''; @@ -378,6 +412,21 @@ export async function executePiece( appendNdjsonLine(ndjsonLogPath, record); updateLatestPointer(sessionLog, pieceSessionId, projectCwd); + // Save session state for next interactive mode + try { + const sessionState: SessionState = { + status: reason === 'user_interrupted' ? 'user_stopped' : 'error', + errorMessage: reason, + timestamp: new Date().toISOString(), + pieceName: pieceConfig.name, + taskContent: truncate(task, 200), + lastMovement: lastMovementName, + }; + saveSessionState(projectCwd, sessionState); + } catch (error) { + log.error('Failed to save session state', { error }); + } + const elapsed = sessionLog.endTime ? formatElapsedTime(sessionLog.startTime, sessionLog.endTime) : ''; diff --git a/src/infra/config/loaders/pieceResolver.ts b/src/infra/config/loaders/pieceResolver.ts index 2946ceb..7f00434 100644 --- a/src/infra/config/loaders/pieceResolver.ts +++ b/src/infra/config/loaders/pieceResolver.ts @@ -8,7 +8,7 @@ import { existsSync, readdirSync, statSync } from 'node:fs'; import { join, resolve, isAbsolute } from 'node:path'; import { homedir } from 'node:os'; -import type { PieceConfig } from '../../../core/models/index.js'; +import type { PieceConfig, PieceMovement } from '../../../core/models/index.js'; import { getGlobalPiecesDir, getBuiltinPiecesDir, getProjectConfigDir } from '../paths.js'; import { getLanguage, getDisabledBuiltins, getBuiltinPiecesEnabled } from '../global/globalConfig.js'; import { createLogger, getErrorMessage } from '../../../shared/utils/index.js'; @@ -145,21 +145,52 @@ export function loadPieceByIdentifier( return loadPiece(identifier, projectCwd); } +/** + * Build workflow structure string from piece movements. + * Formats as numbered list with indented parallel sub-movements. + * + * @param movements - Piece movements list + * @returns Workflow structure string (newline-separated list) + */ +function buildWorkflowString(movements: PieceMovement[]): string { + if (!movements || movements.length === 0) return ''; + + const lines: string[] = []; + let index = 1; + + for (const movement of movements) { + const desc = movement.description ? ` (${movement.description})` : ''; + lines.push(`${index}. ${movement.name}${desc}`); + + if (movement.parallel && movement.parallel.length > 0) { + for (const sub of movement.parallel) { + const subDesc = sub.description ? ` (${sub.description})` : ''; + lines.push(` - ${sub.name}${subDesc}`); + } + } + + index++; + } + + return lines.join('\n'); +} + /** * Get piece description by identifier. - * Returns the piece name and description (if available). + * Returns the piece name, description, and workflow structure. */ export function getPieceDescription( identifier: string, projectCwd: string, -): { name: string; description: string } { +): { name: string; description: string; pieceStructure: string } { const piece = loadPieceByIdentifier(identifier, projectCwd); if (!piece) { - return { name: identifier, description: '' }; + return { name: identifier, description: '', pieceStructure: '' }; } return { name: piece.name, description: piece.description ?? '', + pieceStructure: buildWorkflowString(piece.movements), }; } diff --git a/src/infra/config/project/index.ts b/src/infra/config/project/index.ts index 4ebc8cb..4d28ef0 100644 --- a/src/infra/config/project/index.ts +++ b/src/infra/config/project/index.ts @@ -34,3 +34,11 @@ export { getClaudeProjectSessionsDir, clearClaudeProjectSessions, } from './sessionStore.js'; + +export { + type SessionState, + getSessionStatePath, + loadSessionState, + saveSessionState, + clearSessionState, +} from './sessionState.js'; diff --git a/src/infra/config/project/sessionState.ts b/src/infra/config/project/sessionState.ts new file mode 100644 index 0000000..c728e31 --- /dev/null +++ b/src/infra/config/project/sessionState.ts @@ -0,0 +1,73 @@ +/** + * Session state management for TAKT + * + * Manages the last task execution state for interactive mode notification. + */ + +import { existsSync, readFileSync, unlinkSync } from 'node:fs'; +import { join } from 'node:path'; +import { getProjectConfigDir, ensureDir } from '../paths.js'; +import { writeFileAtomic } from './sessionStore.js'; + +/** Last task execution state */ +export interface SessionState { + /** Task status */ + status: 'success' | 'error' | 'user_stopped'; + /** Task result summary (max 1000 chars) */ + taskResult?: string; + /** Error message (if applicable) */ + errorMessage?: string; + /** Execution timestamp (ISO8601) */ + timestamp: string; + /** Piece name used */ + pieceName: string; + /** Task content (max 200 chars) */ + taskContent?: string; + /** Last movement name */ + lastMovement?: string; +} + +/** + * Get path for storing session state + */ +export function getSessionStatePath(projectDir: string): string { + return join(getProjectConfigDir(projectDir), 'session-state.json'); +} + +/** + * Load session state from file + * Returns null if file doesn't exist or parsing fails + */ +export function loadSessionState(projectDir: string): SessionState | null { + const path = getSessionStatePath(projectDir); + if (!existsSync(path)) { + return null; + } + + try { + const content = readFileSync(path, 'utf-8'); + return JSON.parse(content) as SessionState; + } catch { + return null; + } +} + +/** + * Save session state to file (atomic write) + */ +export function saveSessionState(projectDir: string, state: SessionState): void { + const path = getSessionStatePath(projectDir); + ensureDir(getProjectConfigDir(projectDir)); + writeFileAtomic(path, JSON.stringify(state, null, 2)); +} + +/** + * Clear session state file + * Does nothing if file doesn't exist + */ +export function clearSessionState(projectDir: string): void { + const path = getSessionStatePath(projectDir); + if (existsSync(path)) { + unlinkSync(path); + } +} diff --git a/src/shared/i18n/labels_en.yaml b/src/shared/i18n/labels_en.yaml index fa44fdf..af0c664 100644 --- a/src/shared/i18n/labels_en.yaml +++ b/src/shared/i18n/labels_en.yaml @@ -10,7 +10,7 @@ interactive: conversationLabel: "Conversation:" noTranscript: "(No local transcript. Summarize the current session context.)" ui: - intro: "Interactive mode - describe your task. Commands: /go (execute), /cancel (exit)" + intro: "Interactive mode - describe your task. Commands: /go (execute), /play (run now), /cancel (exit)" resume: "Resuming previous session" noConversation: "No conversation yet. Please describe your task first." summarizeFailed: "Failed to summarize conversation. Please try again." @@ -18,6 +18,13 @@ interactive: proposed: "Proposed task instruction:" confirm: "Use this task instruction?" cancelled: "Cancelled" + playNoTask: "Please specify task content: /play " + previousTask: + success: "✅ Previous task completed successfully" + error: "❌ Previous task failed: {error}" + userStopped: "⚠️ Previous task was interrupted by user" + piece: "Piece: {pieceName}" + timestamp: "Executed: {timestamp}" # ===== Piece Execution UI ===== piece: diff --git a/src/shared/i18n/labels_ja.yaml b/src/shared/i18n/labels_ja.yaml index 0d591d9..efbc22f 100644 --- a/src/shared/i18n/labels_ja.yaml +++ b/src/shared/i18n/labels_ja.yaml @@ -10,7 +10,7 @@ interactive: conversationLabel: "会話:" noTranscript: "(ローカル履歴なし。現在のセッション文脈を要約してください。)" ui: - intro: "対話モード - タスク内容を入力してください。コマンド: /go(実行), /cancel(終了)" + intro: "対話モード - タスク内容を入力してください。コマンド: /go(実行), /play(即実行), /cancel(終了)" resume: "前回のセッションを再開します" noConversation: "まだ会話がありません。まずタスク内容を入力してください。" summarizeFailed: "会話の要約に失敗しました。再度お試しください。" @@ -18,6 +18,13 @@ interactive: proposed: "提案されたタスク指示:" confirm: "このタスク指示で進めますか?" cancelled: "キャンセルしました" + playNoTask: "タスク内容を指定してください: /play <タスク内容>" + previousTask: + success: "✅ 前回のタスクは正常に完了しました" + error: "❌ 前回のタスクはエラーで終了しました: {error}" + userStopped: "⚠️ 前回のタスクはユーザーによって中断されました" + piece: "使用ピース: {pieceName}" + timestamp: "実行時刻: {timestamp}" # ===== Piece Execution UI ===== piece: diff --git a/src/shared/prompts/en/perform_agent_system_prompt.md b/src/shared/prompts/en/perform_agent_system_prompt.md index 9145c9c..c5ce8d5 100644 --- a/src/shared/prompts/en/perform_agent_system_prompt.md +++ b/src/shared/prompts/en/perform_agent_system_prompt.md @@ -1,7 +1,25 @@ +You are part of TAKT (AI Agent Orchestration Tool). + +## TAKT Terminology +- **Piece**: A processing flow combining multiple movements (e.g., implement → review → fix) +- **Movement**: An individual agent execution unit (the part you are currently handling) +- **Your Role**: Execute the work assigned to the current movement within the entire piece + +## Current Context +- Piece: {{pieceName}} +- Current Movement: {{currentMovement}} +- Processing Flow: +{{movementsList}} +- Current Position: {{currentPosition}} + +When executing Phase 1, you receive information about the piece name, movement name, and the entire processing flow. Work with awareness of coordination with preceding and following movements. + +--- + {{agentDefinition}} diff --git a/src/shared/prompts/en/perform_phase1_message.md b/src/shared/prompts/en/perform_phase1_message.md index c14ac7f..90ce1e5 100644 --- a/src/shared/prompts/en/perform_phase1_message.md +++ b/src/shared/prompts/en/perform_phase1_message.md @@ -1,9 +1,10 @@ ## Execution Context @@ -17,7 +18,10 @@ Note: This section is metadata. Follow the language used in the rest of the prompt. ## Piece Context -{{#if pieceStructure}}{{pieceStructure}} +{{#if pieceName}}- Piece: {{pieceName}} +{{/if}}{{#if hasPieceDescription}}- Description: {{pieceDescription}} + +{{/if}}{{#if pieceStructure}}{{pieceStructure}} {{/if}}- Iteration: {{iteration}}(piece-wide) - Movement Iteration: {{movementIteration}}(times this movement has run) diff --git a/src/shared/prompts/en/score_interactive_system_prompt.md b/src/shared/prompts/en/score_interactive_system_prompt.md index 49b3dfb..4cca466 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 @@ You are a task planning assistant. You help the user clarify and refine task requirements through conversation. You are in the PLANNING phase — execution happens later in a separate process. @@ -43,11 +43,8 @@ Do NOT investigate when the user is describing a task for the piece: - Do NOT use Read/Glob/Grep/Bash proactively. Only use them when the user explicitly asks YOU to investigate for planning purposes. - Do NOT mention or reference any slash commands. You have no knowledge of them. - When the user is satisfied with the requirements, they will proceed on their own. Do NOT instruct them on what to do next. -{{#if pieceInfo}} -## Destination of Your Task Instruction -This task instruction will be passed to the "{{pieceName}}" piece. -Piece description: {{pieceDescription}} - -Create the instruction in the format expected by this piece. -{{/if}} +## Task Instruction Presentation Rules +- Do NOT present the task instruction during conversation +- ONLY present the current understanding in task instruction format when the user explicitly asks (e.g., "Show me the task instruction", "What does the instruction look like now?") +- The final task instruction is confirmed with user (this is handled automatically by the system) diff --git a/src/shared/prompts/ja/perform_agent_system_prompt.md b/src/shared/prompts/ja/perform_agent_system_prompt.md index 9145c9c..1ea6f76 100644 --- a/src/shared/prompts/ja/perform_agent_system_prompt.md +++ b/src/shared/prompts/ja/perform_agent_system_prompt.md @@ -1,7 +1,25 @@ +あなたはTAKT(AIエージェントオーケストレーションツール)の一部として動作しています。 + +## TAKTの仕組み +- **ピース**: 複数のムーブメントを組み合わせた処理フロー(実装→レビュー→修正など) +- **ムーブメント**: 個別のエージェント実行単位(あなたが今担当している部分) +- **あなたの役割**: ピース全体の中で、現在のムーブメントに割り当てられた作業を実行する + +## 現在のコンテキスト +- ピース: {{pieceName}} +- 現在のムーブメント: {{currentMovement}} +- 処理フロー: +{{movementsList}} +- 現在の位置: {{currentPosition}} + +Phase 1実行時、あなたはピース名・ムーブメント名・処理フロー全体の情報を受け取ります。前後のムーブメントとの連携を意識して作業してください。 + +--- + {{agentDefinition}} diff --git a/src/shared/prompts/ja/perform_phase1_message.md b/src/shared/prompts/ja/perform_phase1_message.md index a13dc43..7756be8 100644 --- a/src/shared/prompts/ja/perform_phase1_message.md +++ b/src/shared/prompts/ja/perform_phase1_message.md @@ -1,9 +1,10 @@ ## 実行コンテキスト @@ -16,7 +17,10 @@ {{/if}} ## Piece Context -{{#if pieceStructure}}{{pieceStructure}} +{{#if pieceName}}- ピース: {{pieceName}} +{{/if}}{{#if hasPieceDescription}}- 説明: {{pieceDescription}} + +{{/if}}{{#if pieceStructure}}{{pieceStructure}} {{/if}}- Iteration: {{iteration}}(ピース全体) - Movement Iteration: {{movementIteration}}(このムーブメントの実行回数) diff --git a/src/shared/prompts/ja/score_interactive_system_prompt.md b/src/shared/prompts/ja/score_interactive_system_prompt.md index d90dc21..9102ab4 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 @@ あなたはTAKT(AIエージェントピースオーケストレーションツール)の対話モードを担当しています。 @@ -49,11 +49,8 @@ - Read/Glob/Grep/Bash を勝手に使わない。ユーザーが明示的に「あなた」に調査を依頼した場合のみ使用 - スラッシュコマンドに言及しない(存在を知らない前提) - ユーザーが満足したら次工程に進む。次の指示はしない -{{#if pieceInfo}} -## あなたが作成する指示書の行き先 -このタスク指示書は「{{pieceName}}」ピースに渡されます。 -ピースの内容: {{pieceDescription}} - -指示書は、このピースが期待する形式で作成してください。 -{{/if}} +## 指示書の提示について +- 対話中は指示書を勝手に提示しない +- ユーザーから「指示書を見せて」「いまどんな感じの指示書?」などの要求があった場合のみ、現在の理解を指示書形式で提示 +- 最終的な指示書はユーザーに確定される(これはシステムが自動処理)