TAKTのメタ情報をエージェントに引き渡す。またTAKTの前回セッションの情報をscoreフェーズに追加 resolved #89

This commit is contained in:
nrslib 2026-02-05 09:20:18 +09:00
parent e932d647c6
commit 792f61df55
26 changed files with 848 additions and 48 deletions

View File

@ -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

View File

@ -46,6 +46,8 @@ function createMinimalContext(overrides: Partial<InstructionContext> = {}): 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';

View File

@ -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<typeof vi.fn> };
expect(mockProvider.call).not.toHaveBeenCalled();
expect(result.confirmed).toBe(true);
expect(result.task).toBe('quick task');
});
});
});

View File

@ -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'
);
});
});

View File

@ -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');
});
});

View File

@ -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);
});
});
});

View File

@ -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<string, string> = { 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));

View File

@ -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;
};
}

View File

@ -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();
}

View File

@ -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,
},
};
}

View File

@ -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;

View File

@ -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),

View File

@ -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;
}
/**

View File

@ -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(

View File

@ -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)
: '';

View File

@ -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),
};
}

View File

@ -34,3 +34,11 @@ export {
getClaudeProjectSessionsDir,
clearClaudeProjectSessions,
} from './sessionStore.js';
export {
type SessionState,
getSessionStatePath,
loadSessionState,
saveSessionState,
clearSessionState,
} from './sessionState.js';

View File

@ -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);
}
}

View File

@ -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 <task>"
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:

View File

@ -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:

View File

@ -1,7 +1,25 @@
<!--
template: perform_agent_system_prompt
role: system prompt for user-defined agents
vars: agentDefinition
vars: agentDefinition, pieceName, pieceDescription, currentMovement, movementsList, currentPosition
caller: AgentRunner
-->
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}}

View File

@ -1,9 +1,10 @@
<!--
template: perform_phase1_message
phase: 1 (main execution)
vars: workingDirectory, editRule, pieceStructure, iteration, movementIteration,
movement, hasReport, reportInfo, phaseNote, hasTaskSection, userRequest,
hasPreviousResponse, previousResponse, hasUserInputs, userInputs, instructions
vars: workingDirectory, editRule, pieceName, pieceDescription, hasPieceDescription,
pieceStructure, iteration, movementIteration, movement, hasReport, reportInfo,
phaseNote, hasTaskSection, userRequest, hasPreviousResponse, previousResponse,
hasUserInputs, userInputs, instructions
builder: InstructionBuilder
-->
## 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)

View File

@ -1,7 +1,7 @@
<!--
template: score_interactive_system_prompt
role: system prompt for interactive planning mode
vars: pieceInfo, pieceName, pieceDescription
vars: (none)
caller: features/interactive
-->
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)

View File

@ -1,7 +1,25 @@
<!--
template: perform_agent_system_prompt
role: system prompt for user-defined agents
vars: agentDefinition
vars: agentDefinition, pieceName, pieceDescription, currentMovement, movementsList, currentPosition
caller: AgentRunner
-->
あなたはTAKTAIエージェントオーケストレーションツールの一部として動作しています。
## TAKTの仕組み
- **ピース**: 複数のムーブメントを組み合わせた処理フロー(実装→レビュー→修正など)
- **ムーブメント**: 個別のエージェント実行単位(あなたが今担当している部分)
- **あなたの役割**: ピース全体の中で、現在のムーブメントに割り当てられた作業を実行する
## 現在のコンテキスト
- ピース: {{pieceName}}
- 現在のムーブメント: {{currentMovement}}
- 処理フロー:
{{movementsList}}
- 現在の位置: {{currentPosition}}
Phase 1実行時、あなたはピース名・ムーブメント名・処理フロー全体の情報を受け取ります。前後のムーブメントとの連携を意識して作業してください。
---
{{agentDefinition}}

View File

@ -1,9 +1,10 @@
<!--
template: perform_phase1_message
phase: 1 (main execution)
vars: workingDirectory, editRule, pieceStructure, iteration, movementIteration,
movement, hasReport, reportInfo, phaseNote, hasTaskSection, userRequest,
hasPreviousResponse, previousResponse, hasUserInputs, userInputs, instructions
vars: workingDirectory, editRule, pieceName, pieceDescription, hasPieceDescription,
pieceStructure, iteration, movementIteration, movement, hasReport, reportInfo,
phaseNote, hasTaskSection, userRequest, hasPreviousResponse, previousResponse,
hasUserInputs, userInputs, instructions
builder: InstructionBuilder
-->
## 実行コンテキスト
@ -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}}(このムーブメントの実行回数)

View File

@ -1,7 +1,7 @@
<!--
template: score_interactive_system_prompt
role: system prompt for interactive planning mode
vars: pieceInfo, pieceName, pieceDescription
vars: (none)
caller: features/interactive
-->
あなたはTAKTAIエージェントピースオーケストレーションツールの対話モードを担当しています。
@ -49,11 +49,8 @@
- Read/Glob/Grep/Bash を勝手に使わない。ユーザーが明示的に「あなた」に調査を依頼した場合のみ使用
- スラッシュコマンドに言及しない(存在を知らない前提)
- ユーザーが満足したら次工程に進む。次の指示はしない
{{#if pieceInfo}}
## あなたが作成する指示書の行き先
このタスク指示書は「{{pieceName}}」ピースに渡されます。
ピースの内容: {{pieceDescription}}
指示書は、このピースが期待する形式で作成してください。
{{/if}}
## 指示書の提示について
- 対話中は指示書を勝手に提示しない
- ユーザーから「指示書を見せて」「いまどんな感じの指示書?」などの要求があった場合のみ、現在の理解を指示書形式で提示
- 最終的な指示書はユーザーに確定される(これはシステムが自動処理)