github-issue-154-moodoni4tsuno (#165)

* caffeinate に -d フラグを追加し、ディスプレイスリープ中の App Nap によるプロセス凍結を防止

* takt 対話モードの save_task を takt add と同じ worktree 設定フローに統一

takt 対話モードで Save Task を選択した際に worktree/branch/auto_pr の
設定プロンプトがスキップされ、takt run で clone なしに実行されて成果物が
消失するバグを修正。promptWorktreeSettings() を共通関数として抽出し、
saveTaskFromInteractive() と addTask() の両方から使用するようにした。

* Release v0.9.0

* takt: github-issue-154-moodoni4tsuno
This commit is contained in:
nrs 2026-02-09 00:18:29 +09:00 committed by GitHub
parent c542dc0896
commit f7d540b069
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 1524 additions and 287 deletions

View File

@ -45,6 +45,11 @@ vi.mock('../features/pipeline/index.js', () => ({
vi.mock('../features/interactive/index.js', () => ({
interactiveMode: vi.fn(),
selectInteractiveMode: vi.fn(() => 'assistant'),
passthroughMode: vi.fn(),
quietMode: vi.fn(),
personaMode: vi.fn(),
resolveLanguage: vi.fn(() => 'en'),
}));
vi.mock('../infra/config/index.js', () => ({

View File

@ -0,0 +1,532 @@
/**
* Tests for interactive mode variants (assistant, persona, quiet, passthrough)
*/
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
// ── Mocks ──────────────────────────────────────────────
vi.mock('../infra/config/global/globalConfig.js', () => ({
loadGlobalConfig: vi.fn(() => ({ provider: 'mock', language: 'en' })),
getBuiltinPiecesEnabled: vi.fn().mockReturnValue(true),
}));
vi.mock('../infra/providers/index.js', () => ({
getProvider: vi.fn(),
}));
vi.mock('../shared/utils/index.js', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
createLogger: () => ({
info: vi.fn(),
debug: vi.fn(),
error: vi.fn(),
}),
}));
vi.mock('../shared/context.js', () => ({
isQuietMode: vi.fn(() => false),
}));
vi.mock('../infra/config/paths.js', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
loadPersonaSessions: vi.fn(() => ({})),
updatePersonaSession: vi.fn(),
getProjectConfigDir: vi.fn(() => '/tmp'),
loadSessionState: vi.fn(() => null),
clearSessionState: vi.fn(),
}));
vi.mock('../shared/ui/index.js', () => ({
info: vi.fn(),
error: vi.fn(),
blankLine: vi.fn(),
StreamDisplay: vi.fn().mockImplementation(() => ({
createHandler: vi.fn(() => vi.fn()),
flush: vi.fn(),
})),
}));
vi.mock('../shared/prompt/index.js', () => ({
selectOption: vi.fn(),
selectOptionWithDefault: vi.fn(),
}));
import { getProvider } from '../infra/providers/index.js';
import { selectOptionWithDefault, selectOption } from '../shared/prompt/index.js';
const mockGetProvider = vi.mocked(getProvider);
const mockSelectOptionWithDefault = vi.mocked(selectOptionWithDefault);
const mockSelectOption = vi.mocked(selectOption);
// ── Stdin helpers (same pattern as interactive.test.ts) ──
let savedIsTTY: boolean | undefined;
let savedIsRaw: boolean | undefined;
let savedSetRawMode: typeof process.stdin.setRawMode | undefined;
let savedStdoutWrite: typeof process.stdout.write;
let savedStdinOn: typeof process.stdin.on;
let savedStdinRemoveListener: typeof process.stdin.removeListener;
let savedStdinResume: typeof process.stdin.resume;
let savedStdinPause: typeof process.stdin.pause;
function setupRawStdin(rawInputs: string[]): void {
savedIsTTY = process.stdin.isTTY;
savedIsRaw = process.stdin.isRaw;
savedSetRawMode = process.stdin.setRawMode;
savedStdoutWrite = process.stdout.write;
savedStdinOn = process.stdin.on;
savedStdinRemoveListener = process.stdin.removeListener;
savedStdinResume = process.stdin.resume;
savedStdinPause = process.stdin.pause;
Object.defineProperty(process.stdin, 'isTTY', { value: true, configurable: true });
Object.defineProperty(process.stdin, 'isRaw', { value: false, configurable: true, writable: true });
process.stdin.setRawMode = vi.fn((mode: boolean) => {
(process.stdin as unknown as { isRaw: boolean }).isRaw = mode;
return process.stdin;
}) as unknown as typeof process.stdin.setRawMode;
process.stdout.write = vi.fn(() => true) as unknown as typeof process.stdout.write;
process.stdin.resume = vi.fn(() => process.stdin) as unknown as typeof process.stdin.resume;
process.stdin.pause = vi.fn(() => process.stdin) as unknown as typeof process.stdin.pause;
let currentHandler: ((data: Buffer) => void) | null = null;
let inputIndex = 0;
process.stdin.on = vi.fn(((event: string, handler: (...args: unknown[]) => void) => {
if (event === 'data') {
currentHandler = handler as (data: Buffer) => void;
if (inputIndex < rawInputs.length) {
const data = rawInputs[inputIndex]!;
inputIndex++;
queueMicrotask(() => {
if (currentHandler) {
currentHandler(Buffer.from(data, 'utf-8'));
}
});
}
}
return process.stdin;
}) as typeof process.stdin.on);
process.stdin.removeListener = vi.fn(((event: string) => {
if (event === 'data') {
currentHandler = null;
}
return process.stdin;
}) as typeof process.stdin.removeListener);
}
function restoreStdin(): void {
if (savedIsTTY !== undefined) {
Object.defineProperty(process.stdin, 'isTTY', { value: savedIsTTY, configurable: true });
}
if (savedIsRaw !== undefined) {
Object.defineProperty(process.stdin, 'isRaw', { value: savedIsRaw, configurable: true, writable: true });
}
if (savedSetRawMode) process.stdin.setRawMode = savedSetRawMode;
if (savedStdoutWrite) process.stdout.write = savedStdoutWrite;
if (savedStdinOn) process.stdin.on = savedStdinOn;
if (savedStdinRemoveListener) process.stdin.removeListener = savedStdinRemoveListener;
if (savedStdinResume) process.stdin.resume = savedStdinResume;
if (savedStdinPause) process.stdin.pause = savedStdinPause;
}
function toRawInputs(inputs: (string | null)[]): string[] {
return inputs.map((input) => {
if (input === null) return '\x04';
return input + '\r';
});
}
function setupMockProvider(responses: string[]): void {
let callIndex = 0;
const mockCall = vi.fn(async () => {
const content = callIndex < responses.length ? responses[callIndex] : 'AI response';
callIndex++;
return {
persona: 'interactive',
status: 'done' as const,
content: content!,
timestamp: new Date(),
};
});
const mockSetup = vi.fn(() => ({ call: mockCall }));
const mockProvider = {
setup: mockSetup,
_call: mockCall,
_setup: mockSetup,
};
mockGetProvider.mockReturnValue(mockProvider);
}
// ── Imports (after mocks) ──
import { INTERACTIVE_MODES, DEFAULT_INTERACTIVE_MODE } from '../core/models/interactive-mode.js';
import { selectInteractiveMode } from '../features/interactive/modeSelection.js';
import { passthroughMode } from '../features/interactive/passthroughMode.js';
import { quietMode } from '../features/interactive/quietMode.js';
import { personaMode } from '../features/interactive/personaMode.js';
import type { PieceContext } from '../features/interactive/interactive.js';
import type { FirstMovementInfo } from '../infra/config/loaders/pieceResolver.js';
// ── Setup ──
beforeEach(() => {
vi.clearAllMocks();
mockSelectOptionWithDefault.mockResolvedValue('assistant');
mockSelectOption.mockResolvedValue('execute');
});
afterEach(() => {
restoreStdin();
});
// ── InteractiveMode type & constants tests ──
describe('InteractiveMode type', () => {
it('should define all four modes', () => {
expect(INTERACTIVE_MODES).toEqual(['assistant', 'persona', 'quiet', 'passthrough']);
});
it('should have assistant as default mode', () => {
expect(DEFAULT_INTERACTIVE_MODE).toBe('assistant');
});
});
// ── Mode selection tests ──
describe('selectInteractiveMode', () => {
it('should call selectOptionWithDefault with four mode options', async () => {
// When
await selectInteractiveMode('en');
// Then
expect(mockSelectOptionWithDefault).toHaveBeenCalledWith(
expect.any(String),
expect.arrayContaining([
expect.objectContaining({ value: 'assistant' }),
expect.objectContaining({ value: 'persona' }),
expect.objectContaining({ value: 'quiet' }),
expect.objectContaining({ value: 'passthrough' }),
]),
'assistant',
);
});
it('should use piece default when provided', async () => {
// When
await selectInteractiveMode('en', 'quiet');
// Then
expect(mockSelectOptionWithDefault).toHaveBeenCalledWith(
expect.any(String),
expect.any(Array),
'quiet',
);
});
it('should return null when user cancels', async () => {
// Given
mockSelectOptionWithDefault.mockResolvedValue(null);
// When
const result = await selectInteractiveMode('en');
// Then
expect(result).toBeNull();
});
it('should return selected mode value', async () => {
// Given
mockSelectOptionWithDefault.mockResolvedValue('persona');
// When
const result = await selectInteractiveMode('ja');
// Then
expect(result).toBe('persona');
});
it('should present options in correct order', async () => {
// When
await selectInteractiveMode('en');
// Then
const options = mockSelectOptionWithDefault.mock.calls[0]?.[1] as Array<{ value: string }>;
expect(options?.[0]?.value).toBe('assistant');
expect(options?.[1]?.value).toBe('persona');
expect(options?.[2]?.value).toBe('quiet');
expect(options?.[3]?.value).toBe('passthrough');
});
});
// ── Passthrough mode tests ──
describe('passthroughMode', () => {
it('should return initialInput directly when provided', async () => {
// When
const result = await passthroughMode('en', 'my task text');
// Then
expect(result.action).toBe('execute');
expect(result.task).toBe('my task text');
});
it('should return cancel when user sends EOF', async () => {
// Given
setupRawStdin(toRawInputs([null]));
// When
const result = await passthroughMode('en');
// Then
expect(result.action).toBe('cancel');
expect(result.task).toBe('');
});
it('should return cancel when user enters empty input', async () => {
// Given
setupRawStdin(toRawInputs(['']));
// When
const result = await passthroughMode('en');
// Then
expect(result.action).toBe('cancel');
});
it('should return user input as task when entered', async () => {
// Given
setupRawStdin(toRawInputs(['implement login feature']));
// When
const result = await passthroughMode('en');
// Then
expect(result.action).toBe('execute');
expect(result.task).toBe('implement login feature');
});
it('should trim whitespace from user input', async () => {
// Given
setupRawStdin(toRawInputs([' my task ']));
// When
const result = await passthroughMode('en');
// Then
expect(result.task).toBe('my task');
});
});
// ── Quiet mode tests ──
describe('quietMode', () => {
it('should generate instructions from initialInput without questions', async () => {
// Given
setupMockProvider(['Generated task instruction for login feature.']);
mockSelectOption.mockResolvedValue('execute');
// When
const result = await quietMode('/project', 'implement login feature');
// Then
expect(result.action).toBe('execute');
expect(result.task).toBe('Generated task instruction for login feature.');
});
it('should return cancel when user sends EOF for input', async () => {
// Given
setupRawStdin(toRawInputs([null]));
setupMockProvider([]);
// When
const result = await quietMode('/project');
// Then
expect(result.action).toBe('cancel');
});
it('should return cancel when user enters empty input', async () => {
// Given
setupRawStdin(toRawInputs(['']));
setupMockProvider([]);
// When
const result = await quietMode('/project');
// Then
expect(result.action).toBe('cancel');
});
it('should prompt for input when no initialInput is provided', async () => {
// Given
setupRawStdin(toRawInputs(['fix the bug']));
setupMockProvider(['Fix the bug instruction.']);
mockSelectOption.mockResolvedValue('execute');
// When
const result = await quietMode('/project');
// Then
expect(result.action).toBe('execute');
expect(result.task).toBe('Fix the bug instruction.');
});
it('should include piece context in summary generation', async () => {
// Given
const pieceContext: PieceContext = {
name: 'test-piece',
description: 'A test piece',
pieceStructure: '1. implement\n2. review',
movementPreviews: [],
};
setupMockProvider(['Instruction with piece context.']);
mockSelectOption.mockResolvedValue('execute');
// When
const result = await quietMode('/project', 'some task', pieceContext);
// Then
expect(result.action).toBe('execute');
expect(result.task).toBe('Instruction with piece context.');
});
});
// ── Persona mode tests ──
describe('personaMode', () => {
const mockFirstMovement: FirstMovementInfo = {
personaContent: 'You are a senior coder. Write clean, maintainable code.',
personaDisplayName: 'Coder',
allowedTools: ['Read', 'Glob', 'Grep', 'Edit', 'Write', 'Bash'],
};
it('should return cancel when user types /cancel', async () => {
// Given
setupRawStdin(toRawInputs(['/cancel']));
setupMockProvider([]);
// When
const result = await personaMode('/project', mockFirstMovement);
// Then
expect(result.action).toBe('cancel');
expect(result.task).toBe('');
});
it('should return cancel on EOF', async () => {
// Given
setupRawStdin(toRawInputs([null]));
setupMockProvider([]);
// When
const result = await personaMode('/project', mockFirstMovement);
// Then
expect(result.action).toBe('cancel');
});
it('should use first movement persona as system prompt', async () => {
// Given
setupRawStdin(toRawInputs(['fix bug', '/cancel']));
setupMockProvider(['I see the issue.']);
// When
await personaMode('/project', mockFirstMovement);
// Then: the provider should be set up with persona content as system prompt
const mockProvider = mockGetProvider.mock.results[0]!.value as { _setup: ReturnType<typeof vi.fn> };
expect(mockProvider._setup).toHaveBeenCalledWith(
expect.objectContaining({
systemPrompt: 'You are a senior coder. Write clean, maintainable code.',
}),
);
});
it('should use first movement allowed tools', async () => {
// Given
setupRawStdin(toRawInputs(['check the code', '/cancel']));
setupMockProvider(['Looking at the code.']);
// When
await personaMode('/project', mockFirstMovement);
// Then
const mockProvider = mockGetProvider.mock.results[0]!.value as { _call: ReturnType<typeof vi.fn> };
expect(mockProvider._call).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
allowedTools: ['Read', 'Glob', 'Grep', 'Edit', 'Write', 'Bash'],
}),
);
});
it('should process initialInput as first message', async () => {
// Given
setupRawStdin(toRawInputs(['/go']));
setupMockProvider(['I analyzed the issue.', 'Task summary.']);
mockSelectOption.mockResolvedValue('execute');
// When
const result = await personaMode('/project', mockFirstMovement, 'fix the login');
// Then
expect(result.action).toBe('execute');
const mockProvider = mockGetProvider.mock.results[0]!.value as { _call: ReturnType<typeof vi.fn> };
expect(mockProvider._call).toHaveBeenCalledTimes(2);
const firstPrompt = mockProvider._call.mock.calls[0]?.[0] as string;
expect(firstPrompt).toBe('fix the login');
});
it('should handle /play command', async () => {
// Given
setupRawStdin(toRawInputs(['/play direct task text']));
setupMockProvider([]);
// When
const result = await personaMode('/project', mockFirstMovement);
// Then
expect(result.action).toBe('execute');
expect(result.task).toBe('direct task text');
});
it('should fall back to default tools when first movement has none', async () => {
// Given
const noToolsMovement: FirstMovementInfo = {
personaContent: 'Persona prompt',
personaDisplayName: 'Agent',
allowedTools: [],
};
setupRawStdin(toRawInputs(['test', '/cancel']));
setupMockProvider(['response']);
// When
await personaMode('/project', noToolsMovement);
// Then
const mockProvider = mockGetProvider.mock.results[0]!.value as { _call: ReturnType<typeof vi.fn> };
expect(mockProvider._call).toHaveBeenCalledWith(
expect.any(String),
expect.objectContaining({
allowedTools: ['Read', 'Glob', 'Grep', 'Bash', 'WebSearch', 'WebFetch'],
}),
);
});
it('should handle multi-turn conversation before /go', async () => {
// Given
setupRawStdin(toRawInputs(['first message', 'second message', '/go']));
setupMockProvider(['reply 1', 'reply 2', 'Final summary.']);
mockSelectOption.mockResolvedValue('execute');
// When
const result = await personaMode('/project', mockFirstMovement);
// Then
expect(result.action).toBe('execute');
expect(result.task).toBe('Final summary.');
});
});

View File

@ -563,3 +563,215 @@ movements:
expect(result.movementPreviews[0].personaDisplayName).toBe('Custom Agent Name');
});
});
describe('getPieceDescription interactiveMode field', () => {
let tempDir: string;
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), 'takt-test-interactive-mode-'));
});
afterEach(() => {
rmSync(tempDir, { recursive: true, force: true });
});
it('should return interactiveMode when piece defines interactive_mode', () => {
const pieceYaml = `name: test-mode
initial_movement: step1
max_iterations: 1
interactive_mode: quiet
movements:
- name: step1
persona: agent
instruction: "Do something"
`;
const piecePath = join(tempDir, 'test-mode.yaml');
writeFileSync(piecePath, pieceYaml);
const result = getPieceDescription(piecePath, tempDir);
expect(result.interactiveMode).toBe('quiet');
});
it('should return undefined interactiveMode when piece omits interactive_mode', () => {
const pieceYaml = `name: test-no-mode
initial_movement: step1
max_iterations: 1
movements:
- name: step1
persona: agent
instruction: "Do something"
`;
const piecePath = join(tempDir, 'test-no-mode.yaml');
writeFileSync(piecePath, pieceYaml);
const result = getPieceDescription(piecePath, tempDir);
expect(result.interactiveMode).toBeUndefined();
});
it('should return interactiveMode for each valid mode value', () => {
for (const mode of ['assistant', 'persona', 'quiet', 'passthrough'] as const) {
const pieceYaml = `name: test-${mode}
initial_movement: step1
max_iterations: 1
interactive_mode: ${mode}
movements:
- name: step1
persona: agent
instruction: "Do something"
`;
const piecePath = join(tempDir, `test-${mode}.yaml`);
writeFileSync(piecePath, pieceYaml);
const result = getPieceDescription(piecePath, tempDir);
expect(result.interactiveMode).toBe(mode);
}
});
});
describe('getPieceDescription firstMovement field', () => {
let tempDir: string;
beforeEach(() => {
tempDir = mkdtempSync(join(tmpdir(), 'takt-test-first-movement-'));
});
afterEach(() => {
rmSync(tempDir, { recursive: true, force: true });
});
it('should return firstMovement with inline persona content', () => {
const pieceYaml = `name: test-first
initial_movement: plan
max_iterations: 1
movements:
- name: plan
persona: You are a planner.
persona_name: Planner
instruction: "Plan the task"
allowed_tools:
- Read
- Glob
`;
const piecePath = join(tempDir, 'test-first.yaml');
writeFileSync(piecePath, pieceYaml);
const result = getPieceDescription(piecePath, tempDir);
expect(result.firstMovement).toBeDefined();
expect(result.firstMovement!.personaContent).toBe('You are a planner.');
expect(result.firstMovement!.personaDisplayName).toBe('Planner');
expect(result.firstMovement!.allowedTools).toEqual(['Read', 'Glob']);
});
it('should return firstMovement with persona file content', () => {
const personaContent = '# Expert Planner\nYou plan tasks with precision.';
const personaPath = join(tempDir, 'planner-persona.md');
writeFileSync(personaPath, personaContent);
const pieceYaml = `name: test-persona-file
initial_movement: plan
max_iterations: 1
personas:
planner: ./planner-persona.md
movements:
- name: plan
persona: planner
persona_name: Planner
instruction: "Plan the task"
`;
const piecePath = join(tempDir, 'test-persona-file.yaml');
writeFileSync(piecePath, pieceYaml);
const result = getPieceDescription(piecePath, tempDir);
expect(result.firstMovement).toBeDefined();
expect(result.firstMovement!.personaContent).toBe(personaContent);
});
it('should return undefined firstMovement when initialMovement not found', () => {
const pieceYaml = `name: test-missing
initial_movement: nonexistent
max_iterations: 1
movements:
- name: step1
persona: agent
instruction: "Do something"
`;
const piecePath = join(tempDir, 'test-missing.yaml');
writeFileSync(piecePath, pieceYaml);
const result = getPieceDescription(piecePath, tempDir);
expect(result.firstMovement).toBeUndefined();
});
it('should return empty allowedTools array when movement has no tools', () => {
const pieceYaml = `name: test-no-tools
initial_movement: step1
max_iterations: 1
movements:
- name: step1
persona: agent
persona_name: Agent
instruction: "Do something"
`;
const piecePath = join(tempDir, 'test-no-tools.yaml');
writeFileSync(piecePath, pieceYaml);
const result = getPieceDescription(piecePath, tempDir);
expect(result.firstMovement).toBeDefined();
expect(result.firstMovement!.allowedTools).toEqual([]);
});
it('should fallback to inline persona when personaPath is unreadable', () => {
const personaPath = join(tempDir, 'unreadable.md');
writeFileSync(personaPath, '# Persona');
chmodSync(personaPath, 0o000);
const pieceYaml = `name: test-fallback
initial_movement: step1
max_iterations: 1
personas:
myagent: ./unreadable.md
movements:
- name: step1
persona: myagent
persona_name: Agent
instruction: "Do something"
`;
const piecePath = join(tempDir, 'test-fallback.yaml');
writeFileSync(piecePath, pieceYaml);
try {
const result = getPieceDescription(piecePath, tempDir);
expect(result.firstMovement).toBeDefined();
// personaPath is unreadable, so fallback to empty (persona was resolved to a path)
expect(result.firstMovement!.personaContent).toBe('');
} finally {
chmodSync(personaPath, 0o644);
}
});
});

View File

@ -7,10 +7,19 @@
import { info, error } from '../../shared/ui/index.js';
import { getErrorMessage } from '../../shared/utils/index.js';
import { getLabel } from '../../shared/i18n/index.js';
import { fetchIssue, formatIssueAsTask, checkGhCli, parseIssueNumbers, type GitHubIssue } from '../../infra/github/index.js';
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 {
interactiveMode,
selectInteractiveMode,
passthroughMode,
quietMode,
personaMode,
resolveLanguage,
type InteractiveModeResult,
} from '../../features/interactive/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';
@ -118,16 +127,57 @@ export async function executeDefaultAction(task?: string): Promise<void> {
}
// All paths below go through interactive mode
const globalConfig = loadGlobalConfig();
const lang = resolveLanguage(globalConfig.language);
const pieceId = await determinePiece(resolvedCwd, selectOptions.piece);
if (pieceId === null) {
info('Cancelled');
info(getLabel('interactive.ui.cancelled', lang));
return;
}
const globalConfig = loadGlobalConfig();
const previewCount = globalConfig.interactivePreviewMovements;
const pieceContext = getPieceDescription(pieceId, resolvedCwd, previewCount);
const result = await interactiveMode(resolvedCwd, initialInput, pieceContext);
const pieceDesc = getPieceDescription(pieceId, resolvedCwd, previewCount);
// Mode selection after piece selection
const selectedMode = await selectInteractiveMode(lang, pieceDesc.interactiveMode);
if (selectedMode === null) {
info(getLabel('interactive.ui.cancelled', lang));
return;
}
const pieceContext = {
name: pieceDesc.name,
description: pieceDesc.description,
pieceStructure: pieceDesc.pieceStructure,
movementPreviews: pieceDesc.movementPreviews,
};
let result: InteractiveModeResult;
switch (selectedMode) {
case 'assistant':
result = await interactiveMode(resolvedCwd, initialInput, pieceContext);
break;
case 'passthrough':
result = await passthroughMode(lang, initialInput);
break;
case 'quiet':
result = await quietMode(resolvedCwd, initialInput, pieceContext);
break;
case 'persona': {
if (!pieceDesc.firstMovement) {
info(getLabel('interactive.ui.personaFallback', lang));
result = await interactiveMode(resolvedCwd, initialInput, pieceContext);
} else {
result = await personaMode(resolvedCwd, pieceDesc.firstMovement, initialInput, pieceContext);
}
break;
}
}
switch (result.action) {
case 'execute':

View File

@ -35,6 +35,9 @@ export * from './config.js';
// Re-export from schemas.ts
export * from './schemas.js';
// Re-export from interactive-mode.ts
export { INTERACTIVE_MODES, DEFAULT_INTERACTIVE_MODE, type InteractiveMode } from './interactive-mode.js';
// Re-export from session.ts (functions only, not types)
export {
createSessionState,

View File

@ -0,0 +1,18 @@
/**
* Interactive mode variants for conversational task input.
*
* Defines the four modes available when using interactive mode:
* - assistant: Asks clarifying questions before generating instructions (default)
* - persona: Uses the first movement's persona for conversation
* - quiet: Generates instructions without asking questions (best-effort)
* - passthrough: Passes user input directly as task text
*/
/** Available interactive mode variants */
export const INTERACTIVE_MODES = ['assistant', 'persona', 'quiet', 'passthrough'] as const;
/** Interactive mode type */
export type InteractiveMode = typeof INTERACTIVE_MODES[number];
/** Default interactive mode */
export const DEFAULT_INTERACTIVE_MODE: InteractiveMode = 'assistant';

View File

@ -4,6 +4,7 @@
import type { PermissionMode } from './status.js';
import type { AgentResponse } from './response.js';
import type { InteractiveMode } from './interactive-mode.js';
/** Rule-based transition configuration (unified format) */
export interface PieceRule {
@ -184,6 +185,8 @@ export interface PieceConfig {
* instead of prompting the user interactively.
*/
answerAgent?: string;
/** Default interactive mode for this piece (overrides user default) */
interactiveMode?: InteractiveMode;
}
/** Runtime state of a piece execution */

View File

@ -7,6 +7,7 @@
import { z } from 'zod/v4';
import { DEFAULT_LANGUAGE } from '../../shared/constants.js';
import { McpServersSchema } from './mcp-schemas.js';
import { INTERACTIVE_MODES } from './interactive-mode.js';
export { McpServerConfigSchema, McpServersSchema } from './mcp-schemas.js';
@ -218,6 +219,9 @@ export const LoopMonitorSchema = z.object({
judge: LoopMonitorJudgeSchema,
});
/** Interactive mode schema for piece-level default */
export const InteractiveModeSchema = z.enum(INTERACTIVE_MODES);
/** Piece configuration schema - raw YAML format */
export const PieceConfigRawSchema = z.object({
name: z.string().min(1),
@ -237,6 +241,8 @@ export const PieceConfigRawSchema = z.object({
max_iterations: z.number().int().positive().optional().default(10),
loop_monitors: z.array(LoopMonitorSchema).optional(),
answer_agent: z.string().optional(),
/** Default interactive mode for this piece (overrides user default) */
interactive_mode: InteractiveModeSchema.optional(),
});
/** Custom agent configuration schema */

View File

@ -0,0 +1,300 @@
/**
* Shared conversation loop for interactive modes (assistant & persona).
*
* Extracts the common patterns:
* - Provider/session initialization
* - AI call with retry on stale session
* - Session state display/clear
* - Conversation loop (slash commands, AI messaging, /go summary)
*/
import chalk from 'chalk';
import {
loadGlobalConfig,
loadPersonaSessions,
updatePersonaSession,
loadSessionState,
clearSessionState,
} from '../../infra/config/index.js';
import { isQuietMode } from '../../shared/context.js';
import { getProvider, type ProviderType } from '../../infra/providers/index.js';
import { createLogger, getErrorMessage } from '../../shared/utils/index.js';
import { info, error, blankLine, StreamDisplay } from '../../shared/ui/index.js';
import { getLabel, getLabelObject } from '../../shared/i18n/index.js';
import { readMultilineInput } from './lineEditor.js';
import {
type PieceContext,
type InteractiveModeResult,
type InteractiveUIText,
type ConversationMessage,
resolveLanguage,
buildSummaryPrompt,
selectPostSummaryAction,
formatSessionStatus,
} from './interactive.js';
const log = createLogger('conversation-loop');
/** Result from a single AI call */
export interface CallAIResult {
content: string;
sessionId?: string;
success: boolean;
}
/** Initialized session context for conversation loops */
export interface SessionContext {
provider: ReturnType<typeof getProvider>;
providerType: ProviderType;
model: string | undefined;
lang: 'en' | 'ja';
personaName: string;
sessionId: string | undefined;
}
/**
* Initialize provider, session, and language for interactive conversation.
*/
export function initializeSession(cwd: string, personaName: string): SessionContext {
const globalConfig = loadGlobalConfig();
const lang = resolveLanguage(globalConfig.language);
if (!globalConfig.provider) {
throw new Error('Provider is not configured.');
}
const providerType = globalConfig.provider as ProviderType;
const provider = getProvider(providerType);
const model = globalConfig.model as string | undefined;
const savedSessions = loadPersonaSessions(cwd, providerType);
const sessionId: string | undefined = savedSessions[personaName];
return { provider, providerType, model, lang, personaName, sessionId };
}
/**
* Display and clear previous session state if present.
*/
export function displayAndClearSessionState(cwd: string, lang: 'en' | 'ja'): void {
const sessionState = loadSessionState(cwd);
if (sessionState) {
const statusLabel = formatSessionStatus(sessionState, lang);
info(statusLabel);
blankLine();
clearSessionState(cwd);
}
}
/**
* Call AI with automatic retry on stale/invalid session.
*
* On session failure, clears sessionId and retries once without session.
* Updates sessionId and persists it on success.
*/
export async function callAIWithRetry(
prompt: string,
systemPrompt: string,
allowedTools: string[],
cwd: string,
ctx: SessionContext,
): Promise<{ result: CallAIResult | null; sessionId: string | undefined }> {
const display = new StreamDisplay('assistant', isQuietMode());
let { sessionId } = ctx;
try {
const agent = ctx.provider.setup({ name: ctx.personaName, systemPrompt });
const response = await agent.call(prompt, {
cwd,
model: ctx.model,
sessionId,
allowedTools,
onStream: display.createHandler(),
});
display.flush();
const success = response.status !== 'blocked';
if (!success && sessionId) {
log.info('Session invalid, retrying without session');
sessionId = undefined;
const retryDisplay = new StreamDisplay('assistant', isQuietMode());
const retryAgent = ctx.provider.setup({ name: ctx.personaName, systemPrompt });
const retry = await retryAgent.call(prompt, {
cwd,
model: ctx.model,
sessionId: undefined,
allowedTools,
onStream: retryDisplay.createHandler(),
});
retryDisplay.flush();
if (retry.sessionId) {
sessionId = retry.sessionId;
updatePersonaSession(cwd, ctx.personaName, sessionId, ctx.providerType);
}
return {
result: { content: retry.content, sessionId: retry.sessionId, success: retry.status !== 'blocked' },
sessionId,
};
}
if (response.sessionId) {
sessionId = response.sessionId;
updatePersonaSession(cwd, ctx.personaName, sessionId, ctx.providerType);
}
return {
result: { content: response.content, sessionId: response.sessionId, success },
sessionId,
};
} catch (e) {
const msg = getErrorMessage(e);
log.error('AI call failed', { error: msg });
error(msg);
blankLine();
return { result: null, sessionId };
}
}
/** Strategy for customizing conversation loop behavior */
export interface ConversationStrategy {
/** System prompt for AI calls */
systemPrompt: string;
/** Allowed tools for AI calls */
allowedTools: string[];
/** Transform user message before sending to AI (e.g., policy injection) */
transformPrompt: (userMessage: string) => string;
/** Intro message displayed at start */
introMessage: string;
}
/**
* Run the shared conversation loop.
*
* Handles: EOF, /play, /go (summary), /cancel, regular AI messaging.
* The Strategy object controls system prompt, tool access, and prompt transformation.
*/
export async function runConversationLoop(
cwd: string,
ctx: SessionContext,
strategy: ConversationStrategy,
pieceContext: PieceContext | undefined,
initialInput: string | undefined,
): Promise<InteractiveModeResult> {
const history: ConversationMessage[] = [];
let sessionId = ctx.sessionId;
const ui = getLabelObject<InteractiveUIText>('interactive.ui', ctx.lang);
const conversationLabel = getLabel('interactive.conversationLabel', ctx.lang);
const noTranscript = getLabel('interactive.noTranscript', ctx.lang);
info(strategy.introMessage);
if (sessionId) {
info(ui.resume);
}
blankLine();
/** Helper: call AI with current session and update session state */
async function doCallAI(prompt: string, sysPrompt: string, tools: string[]): Promise<CallAIResult | null> {
const { result, sessionId: newSessionId } = await callAIWithRetry(
prompt, sysPrompt, tools, cwd, { ...ctx, sessionId },
);
sessionId = newSessionId;
return result;
}
if (initialInput) {
history.push({ role: 'user', content: initialInput });
log.debug('Processing initial input', { initialInput, sessionId });
const promptWithTransform = strategy.transformPrompt(initialInput);
const result = await doCallAI(promptWithTransform, strategy.systemPrompt, strategy.allowedTools);
if (result) {
if (!result.success) {
error(result.content);
blankLine();
return { action: 'cancel', task: '' };
}
history.push({ role: 'assistant', content: result.content });
blankLine();
} else {
history.pop();
}
}
while (true) {
const input = await readMultilineInput(chalk.green('> '));
if (input === null) {
blankLine();
info(ui.cancelled);
return { action: 'cancel', task: '' };
}
const trimmed = input.trim();
if (!trimmed) {
continue;
}
if (trimmed.startsWith('/play')) {
const task = trimmed.slice(5).trim();
if (!task) {
info(ui.playNoTask);
continue;
}
log.info('Play command', { task });
return { action: 'execute', task };
}
if (trimmed.startsWith('/go')) {
const userNote = trimmed.slice(3).trim();
let summaryPrompt = buildSummaryPrompt(
history, !!sessionId, ctx.lang, noTranscript, conversationLabel, pieceContext,
);
if (!summaryPrompt) {
info(ui.noConversation);
continue;
}
if (userNote) {
summaryPrompt = `${summaryPrompt}\n\nUser Note:\n${userNote}`;
}
const summaryResult = await doCallAI(summaryPrompt, summaryPrompt, strategy.allowedTools);
if (!summaryResult) {
info(ui.summarizeFailed);
continue;
}
if (!summaryResult.success) {
error(summaryResult.content);
blankLine();
return { action: 'cancel', task: '' };
}
const task = summaryResult.content.trim();
const selectedAction = await selectPostSummaryAction(task, ui.proposed, ui);
if (selectedAction === 'continue' || selectedAction === null) {
info(ui.continuePrompt);
continue;
}
log.info('Conversation action selected', { action: selectedAction, messageCount: history.length });
return { action: selectedAction, task };
}
if (trimmed === '/cancel') {
info(ui.cancelled);
return { action: 'cancel', task: '' };
}
history.push({ role: 'user', content: trimmed });
log.debug('Sending to AI', { messageCount: history.length, sessionId });
process.stdin.pause();
const promptWithTransform = strategy.transformPrompt(trimmed);
const result = await doCallAI(promptWithTransform, strategy.systemPrompt, strategy.allowedTools);
if (result) {
if (!result.success) {
error(result.content);
blankLine();
history.pop();
return { action: 'cancel', task: '' };
}
history.push({ role: 'assistant', content: result.content });
blankLine();
} else {
history.pop();
}
}
}

View File

@ -4,7 +4,17 @@
export {
interactiveMode,
resolveLanguage,
buildSummaryPrompt,
selectPostSummaryAction,
formatMovementPreviews,
formatSessionStatus,
type PieceContext,
type InteractiveModeResult,
type InteractiveModeAction,
} from './interactive.js';
export { selectInteractiveMode } from './modeSelection.js';
export { passthroughMode } from './passthroughMode.js';
export { quietMode } from './quietMode.js';
export { personaMode } from './personaMode.js';

View File

@ -10,29 +10,23 @@
* /cancel - Cancel and exit
*/
import chalk from 'chalk';
import type { Language } from '../../core/models/index.js';
import {
loadGlobalConfig,
loadPersonaSessions,
updatePersonaSession,
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';
import { selectOption } from '../../shared/prompt/index.js';
import { createLogger, getErrorMessage } from '../../shared/utils/index.js';
import { info, error, blankLine, StreamDisplay } from '../../shared/ui/index.js';
import { info, blankLine } from '../../shared/ui/index.js';
import { loadTemplate } from '../../shared/prompts/index.js';
import { getLabel, getLabelObject } from '../../shared/i18n/index.js';
import { readMultilineInput } from './lineEditor.js';
const log = createLogger('interactive');
import {
initializeSession,
displayAndClearSessionState,
runConversationLoop,
} from './conversationLoop.js';
/** Shape of interactive UI text */
interface InteractiveUIText {
export interface InteractiveUIText {
intro: string;
resume: string;
noConversation: string;
@ -53,7 +47,7 @@ interface InteractiveUIText {
/**
* Format session state for display
*/
function formatSessionStatus(state: SessionState, lang: 'en' | 'ja'): string {
export function formatSessionStatus(state: SessionState, lang: 'en' | 'ja'): string {
const lines: string[] = [];
// Status line
@ -87,7 +81,7 @@ function formatSessionStatus(state: SessionState, lang: 'en' | 'ja'): string {
return lines.join('\n');
}
function resolveLanguage(lang?: Language): 'en' | 'ja' {
export function resolveLanguage(lang?: Language): 'en' | 'ja' {
return lang === 'ja' ? 'ja' : 'en';
}
@ -122,37 +116,11 @@ export function formatMovementPreviews(previews: MovementPreview[], lang: 'en' |
}).join('\n\n');
}
function getInteractivePrompts(lang: 'en' | 'ja', pieceContext?: PieceContext) {
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 {
systemPrompt,
policyContent,
lang,
pieceContext,
conversationLabel: getLabel('interactive.conversationLabel', lang),
noTranscript: getLabel('interactive.noTranscript', lang),
ui: getLabelObject<InteractiveUIText>('interactive.ui', lang),
};
}
interface ConversationMessage {
export interface ConversationMessage {
role: 'user' | 'assistant';
content: string;
}
interface CallAIResult {
content: string;
sessionId?: string;
success: boolean;
}
/**
* Build the final task description from conversation history for executeTask.
*/
@ -167,7 +135,7 @@ function buildTaskFromHistory(history: ConversationMessage[]): string {
* Renders the complete score_summary_system_prompt template with conversation data.
* Returns empty string if there is no conversation to summarize.
*/
function buildSummaryPrompt(
export function buildSummaryPrompt(
history: ConversationMessage[],
hasSession: boolean,
lang: 'en' | 'ja',
@ -199,9 +167,9 @@ function buildSummaryPrompt(
});
}
type PostSummaryAction = InteractiveModeAction | 'continue';
export type PostSummaryAction = InteractiveModeAction | 'continue';
async function selectPostSummaryAction(
export async function selectPostSummaryAction(
task: string,
proposedLabel: string,
ui: InteractiveUIText,
@ -218,34 +186,6 @@ async function selectPostSummaryAction(
]);
}
/**
* Call AI with the same pattern as piece execution.
* The key requirement is passing onStream the Agent SDK requires
* includePartialMessages to be true for the async iterator to yield.
*/
async function callAI(
provider: ReturnType<typeof getProvider>,
prompt: string,
cwd: string,
model: string | undefined,
sessionId: string | undefined,
display: StreamDisplay,
systemPrompt: string,
): Promise<CallAIResult> {
const agent = provider.setup({ name: 'interactive', systemPrompt });
const response = await agent.call(prompt, {
cwd,
model,
sessionId,
allowedTools: ['Read', 'Glob', 'Grep', 'Bash', 'WebSearch', 'WebFetch'],
onStream: display.createHandler(),
});
display.flush();
const success = response.status !== 'blocked';
return { content: response.content, sessionId: response.sessionId, success };
}
export type InteractiveModeAction = 'execute' | 'save_task' | 'create_issue' | 'cancel';
export interface InteractiveModeResult {
@ -266,6 +206,8 @@ export interface PieceContext {
movementPreviews?: MovementPreview[];
}
export const DEFAULT_INTERACTIVE_TOOLS = ['Read', 'Glob', 'Grep', 'Bash', 'WebSearch', 'WebFetch'];
/**
* Run the interactive task input mode.
*
@ -280,206 +222,37 @@ export async function interactiveMode(
initialInput?: string,
pieceContext?: PieceContext,
): Promise<InteractiveModeResult> {
const globalConfig = loadGlobalConfig();
const lang = resolveLanguage(globalConfig.language);
const prompts = getInteractivePrompts(lang, pieceContext);
if (!globalConfig.provider) {
throw new Error('Provider is not configured.');
}
const providerType = globalConfig.provider as ProviderType;
const provider = getProvider(providerType);
const model = (globalConfig.model as string | undefined);
const ctx = initializeSession(cwd, 'interactive');
const history: ConversationMessage[] = [];
const personaName = 'interactive';
const savedSessions = loadPersonaSessions(cwd, providerType);
let sessionId: string | undefined = savedSessions[personaName];
displayAndClearSessionState(cwd, ctx.lang);
// 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);
}
blankLine();
/** Call AI with automatic retry on session error (stale/invalid session ID). */
async function callAIWithRetry(prompt: string, systemPrompt: string): Promise<CallAIResult | null> {
const display = new StreamDisplay('assistant', isQuietMode());
try {
const result = await callAI(
provider,
prompt,
cwd,
model,
sessionId,
display,
systemPrompt,
);
// If session failed, clear it and retry without session
if (!result.success && sessionId) {
log.info('Session invalid, retrying without session');
sessionId = undefined;
const retryDisplay = new StreamDisplay('assistant', isQuietMode());
const retry = await callAI(
provider,
prompt,
cwd,
model,
undefined,
retryDisplay,
systemPrompt,
);
if (retry.sessionId) {
sessionId = retry.sessionId;
updatePersonaSession(cwd, personaName, sessionId, providerType);
}
return retry;
}
if (result.sessionId) {
sessionId = result.sessionId;
updatePersonaSession(cwd, personaName, sessionId, providerType);
}
return result;
} catch (e) {
const msg = getErrorMessage(e);
log.error('AI call failed', { error: msg });
error(msg);
blankLine();
return null;
}
}
const hasPreview = !!pieceContext?.movementPreviews?.length;
const systemPrompt = loadTemplate('score_interactive_system_prompt', ctx.lang, {
hasPiecePreview: hasPreview,
pieceStructure: pieceContext?.pieceStructure ?? '',
movementDetails: hasPreview ? formatMovementPreviews(pieceContext!.movementPreviews!, ctx.lang) : '',
});
const policyContent = loadTemplate('score_interactive_policy', ctx.lang, {});
const ui = getLabelObject<InteractiveUIText>('interactive.ui', ctx.lang);
/**
* Inject policy into user message for AI call.
* Follows the same pattern as piece execution (perform_phase1_message.md).
*/
function injectPolicy(userMessage: string): string {
const policyIntro = lang === 'ja'
const policyIntro = ctx.lang === 'ja'
? '以下のポリシーは行動規範です。必ず遵守してください。'
: 'The following policy defines behavioral guidelines. Please follow them.';
const reminderLabel = lang === 'ja'
const reminderLabel = ctx.lang === 'ja'
? '上記の Policy セクションで定義されたポリシー規範を遵守してください。'
: 'Please follow the policy guidelines defined in the Policy section above.';
return `## Policy\n${policyIntro}\n\n${prompts.policyContent}\n\n---\n\n${userMessage}\n\n---\n**Policy Reminder:** ${reminderLabel}`;
return `## Policy\n${policyIntro}\n\n${policyContent}\n\n---\n\n${userMessage}\n\n---\n**Policy Reminder:** ${reminderLabel}`;
}
// Process initial input if provided (e.g. from `takt a`)
if (initialInput) {
history.push({ role: 'user', content: initialInput });
log.debug('Processing initial input', { initialInput, sessionId });
const promptWithPolicy = injectPolicy(initialInput);
const result = await callAIWithRetry(promptWithPolicy, prompts.systemPrompt);
if (result) {
if (!result.success) {
error(result.content);
blankLine();
return { action: 'cancel', task: '' };
}
history.push({ role: 'assistant', content: result.content });
blankLine();
} else {
history.pop();
}
}
while (true) {
const input = await readMultilineInput(chalk.green('> '));
// EOF (Ctrl+D)
if (input === null) {
blankLine();
info('Cancelled');
return { action: 'cancel', task: '' };
}
const trimmed = input.trim();
// Empty input — skip
if (!trimmed) {
continue;
}
// 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 { action: 'execute', task };
}
if (trimmed.startsWith('/go')) {
const userNote = trimmed.slice(3).trim();
let summaryPrompt = buildSummaryPrompt(
history,
!!sessionId,
prompts.lang,
prompts.noTranscript,
prompts.conversationLabel,
prompts.pieceContext,
);
if (!summaryPrompt) {
info(prompts.ui.noConversation);
continue;
}
if (userNote) {
summaryPrompt = `${summaryPrompt}\n\nUser Note:\n${userNote}`;
}
const summaryResult = await callAIWithRetry(summaryPrompt, summaryPrompt);
if (!summaryResult) {
info(prompts.ui.summarizeFailed);
continue;
}
if (!summaryResult.success) {
error(summaryResult.content);
blankLine();
return { action: 'cancel', task: '' };
}
const task = summaryResult.content.trim();
const selectedAction = await selectPostSummaryAction(task, prompts.ui.proposed, prompts.ui);
if (selectedAction === 'continue' || selectedAction === null) {
info(prompts.ui.continuePrompt);
continue;
}
log.info('Interactive mode action selected', { action: selectedAction, messageCount: history.length });
return { action: selectedAction, task };
}
if (trimmed === '/cancel') {
info(prompts.ui.cancelled);
return { action: 'cancel', task: '' };
}
// Regular input — send to AI
history.push({ role: 'user', content: trimmed });
log.debug('Sending to AI', { messageCount: history.length, sessionId });
process.stdin.pause();
const promptWithPolicy = injectPolicy(trimmed);
const result = await callAIWithRetry(promptWithPolicy, prompts.systemPrompt);
if (result) {
if (!result.success) {
error(result.content);
blankLine();
history.pop();
return { action: 'cancel', task: '' };
}
history.push({ role: 'assistant', content: result.content });
blankLine();
} else {
history.pop();
}
}
return runConversationLoop(cwd, ctx, {
systemPrompt,
allowedTools: DEFAULT_INTERACTIVE_TOOLS,
transformPrompt: injectPolicy,
introMessage: ui.intro,
}, pieceContext, initialInput);
}

View File

@ -0,0 +1,35 @@
/**
* Interactive mode selection UI.
*
* Presents the four interactive mode options after piece selection
* and returns the user's choice.
*/
import type { InteractiveMode } from '../../core/models/index.js';
import { DEFAULT_INTERACTIVE_MODE, INTERACTIVE_MODES } from '../../core/models/index.js';
import { selectOptionWithDefault } from '../../shared/prompt/index.js';
import { getLabel } from '../../shared/i18n/index.js';
/**
* Prompt the user to select an interactive mode.
*
* @param lang - Display language
* @param pieceDefault - Piece-level default mode (overrides user default)
* @returns Selected mode, or null if cancelled
*/
export async function selectInteractiveMode(
lang: 'en' | 'ja',
pieceDefault?: InteractiveMode,
): Promise<InteractiveMode | null> {
const defaultMode = pieceDefault ?? DEFAULT_INTERACTIVE_MODE;
const options: { label: string; value: InteractiveMode; description: string }[] = INTERACTIVE_MODES.map((mode) => ({
label: getLabel(`interactive.modeSelection.${mode}`, lang),
value: mode,
description: getLabel(`interactive.modeSelection.${mode}Description`, lang),
}));
const prompt = getLabel('interactive.modeSelection.prompt', lang);
return selectOptionWithDefault<InteractiveMode>(prompt, options, defaultMode);
}

View File

@ -0,0 +1,50 @@
/**
* Passthrough interactive mode.
*
* Passes user input directly as the task string without any
* AI-assisted instruction generation or system prompt injection.
*/
import chalk from 'chalk';
import { info, blankLine } from '../../shared/ui/index.js';
import { getLabel } from '../../shared/i18n/index.js';
import { readMultilineInput } from './lineEditor.js';
import type { InteractiveModeResult } from './interactive.js';
/**
* Run passthrough mode: collect user input and return it as-is.
*
* If initialInput is provided, it is used directly as the task.
* Otherwise, prompts the user for input.
*
* @param lang - Display language
* @param initialInput - Pre-filled input (e.g., from issue reference)
* @returns Result with the raw user input as task
*/
export async function passthroughMode(
lang: 'en' | 'ja',
initialInput?: string,
): Promise<InteractiveModeResult> {
if (initialInput) {
return { action: 'execute', task: initialInput };
}
info(getLabel('interactive.ui.intro', lang));
blankLine();
const input = await readMultilineInput(chalk.green('> '));
if (input === null) {
blankLine();
info(getLabel('interactive.ui.cancelled', lang));
return { action: 'cancel', task: '' };
}
const trimmed = input.trim();
if (!trimmed) {
info(getLabel('interactive.ui.cancelled', lang));
return { action: 'cancel', task: '' };
}
return { action: 'execute', task: trimmed };
}

View File

@ -0,0 +1,58 @@
/**
* Persona interactive mode.
*
* Uses the first movement's persona and tools for the interactive
* conversation. The persona acts as the conversational agent,
* performing code exploration and analysis while discussing the task.
* The conversation result is passed as the task to the piece.
*/
import type { FirstMovementInfo } from '../../infra/config/index.js';
import { getLabel } from '../../shared/i18n/index.js';
import {
type PieceContext,
type InteractiveModeResult,
DEFAULT_INTERACTIVE_TOOLS,
} from './interactive.js';
import {
initializeSession,
displayAndClearSessionState,
runConversationLoop,
} from './conversationLoop.js';
/**
* Run persona mode: converse as the first movement's persona.
*
* The persona's system prompt is used for all AI calls.
* The first movement's allowed tools are made available.
* After the conversation, the result is summarized as a task.
*
* @param cwd - Working directory
* @param firstMovement - First movement's persona and tool info
* @param initialInput - Pre-filled input
* @param pieceContext - Piece context for summary generation
* @returns Result with conversation-derived task
*/
export async function personaMode(
cwd: string,
firstMovement: FirstMovementInfo,
initialInput?: string,
pieceContext?: PieceContext,
): Promise<InteractiveModeResult> {
const ctx = initializeSession(cwd, 'persona-interactive');
displayAndClearSessionState(cwd, ctx.lang);
const allowedTools = firstMovement.allowedTools.length > 0
? firstMovement.allowedTools
: DEFAULT_INTERACTIVE_TOOLS;
const introMessage = `${getLabel('interactive.ui.intro', ctx.lang)} [${firstMovement.personaDisplayName}]`;
return runConversationLoop(cwd, ctx, {
systemPrompt: firstMovement.personaContent,
allowedTools,
transformPrompt: (msg) => msg,
introMessage,
}, pieceContext, initialInput);
}

View File

@ -0,0 +1,111 @@
/**
* Quiet interactive mode.
*
* Generates task instructions without asking clarifying questions.
* Uses the same summarization logic as assistant mode but skips
* the conversational loop goes directly to summary generation.
*/
import chalk from 'chalk';
import { createLogger } from '../../shared/utils/index.js';
import { info, error, blankLine } from '../../shared/ui/index.js';
import { getLabel, getLabelObject } from '../../shared/i18n/index.js';
import { readMultilineInput } from './lineEditor.js';
import {
type PieceContext,
type InteractiveModeResult,
type InteractiveUIText,
type ConversationMessage,
DEFAULT_INTERACTIVE_TOOLS,
buildSummaryPrompt,
selectPostSummaryAction,
} from './interactive.js';
import {
initializeSession,
callAIWithRetry,
} from './conversationLoop.js';
const log = createLogger('quiet-mode');
/**
* Run quiet mode: collect user input and generate instructions without questions.
*
* Flow:
* 1. If initialInput is provided, use it; otherwise prompt for input
* 2. Build summary prompt from the user input
* 3. Call AI to generate task instructions (best-effort, no questions)
* 4. Present the result and let user choose action
*
* @param cwd - Working directory
* @param initialInput - Pre-filled input (e.g., from issue reference)
* @param pieceContext - Piece context for template rendering
* @returns Result with generated task instructions
*/
export async function quietMode(
cwd: string,
initialInput?: string,
pieceContext?: PieceContext,
): Promise<InteractiveModeResult> {
const ctx = initializeSession(cwd, 'interactive');
let userInput = initialInput;
if (!userInput) {
info(getLabel('interactive.ui.intro', ctx.lang));
blankLine();
const input = await readMultilineInput(chalk.green('> '));
if (input === null) {
blankLine();
info(getLabel('interactive.ui.cancelled', ctx.lang));
return { action: 'cancel', task: '' };
}
const trimmed = input.trim();
if (!trimmed) {
info(getLabel('interactive.ui.cancelled', ctx.lang));
return { action: 'cancel', task: '' };
}
userInput = trimmed;
}
const history: ConversationMessage[] = [
{ role: 'user', content: userInput },
];
const conversationLabel = getLabel('interactive.conversationLabel', ctx.lang);
const noTranscript = getLabel('interactive.noTranscript', ctx.lang);
const summaryPrompt = buildSummaryPrompt(
history, !!ctx.sessionId, ctx.lang, noTranscript, conversationLabel, pieceContext,
);
if (!summaryPrompt) {
info(getLabel('interactive.ui.noConversation', ctx.lang));
return { action: 'cancel', task: '' };
}
const { result } = await callAIWithRetry(
summaryPrompt, summaryPrompt, DEFAULT_INTERACTIVE_TOOLS, cwd, ctx,
);
if (!result) {
return { action: 'cancel', task: '' };
}
if (!result.success) {
error(result.content);
blankLine();
return { action: 'cancel', task: '' };
}
const task = result.content.trim();
const ui = getLabelObject<InteractiveUIText>('interactive.ui', ctx.lang);
const selectedAction = await selectPostSummaryAction(task, ui.proposed, ui);
if (selectedAction === 'continue' || selectedAction === null) {
return { action: 'cancel', task: '' };
}
log.info('Quiet mode action selected', { action: selectedAction });
return { action: selectedAction, task };
}

View File

@ -13,6 +13,7 @@ export {
listPieces,
listPieceEntries,
type MovementPreview,
type FirstMovementInfo,
type PieceDirEntry,
type PieceSource,
type PieceWithSource,

View File

@ -21,6 +21,7 @@ export {
listPieces,
listPieceEntries,
type MovementPreview,
type FirstMovementInfo,
type PieceDirEntry,
type PieceSource,
type PieceWithSource,

View File

@ -280,6 +280,7 @@ export function normalizePieceConfig(
maxIterations: parsed.max_iterations,
loopMonitors: normalizeLoopMonitors(parsed.loop_monitors, pieceDir, sections, context),
answerAgent: parsed.answer_agent,
interactiveMode: parsed.interactive_mode,
};
}

View File

@ -8,7 +8,7 @@
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';
import type { PieceConfig, PieceMovement, InteractiveMode } 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';
@ -219,24 +219,10 @@ function buildMovementPreviews(piece: PieceConfig, maxCount: number): MovementPr
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,
personaContent: readMovementPersona(movement),
instructionContent: movement.instructionTemplate,
allowedTools: movement.allowedTools ?? [],
canEdit: movement.edit === true,
@ -250,26 +236,86 @@ function buildMovementPreviews(piece: PieceConfig, maxCount: number): MovementPr
return previews;
}
/**
* Read persona content from a movement.
* When personaPath is set, reads from file (returns empty on failure).
* Otherwise uses inline persona string.
*/
function readMovementPersona(movement: PieceMovement): string {
if (movement.personaPath) {
try {
return readFileSync(movement.personaPath, 'utf-8');
} catch (err) {
log.debug('Failed to read persona file', {
path: movement.personaPath,
error: getErrorMessage(err),
});
return '';
}
}
return movement.persona ?? '';
}
/** First movement info for persona mode */
export interface FirstMovementInfo {
/** Persona prompt content */
personaContent: string;
/** Persona display name */
personaDisplayName: string;
/** Allowed tools for this movement */
allowedTools: string[];
}
/**
* Get piece description by identifier.
* Returns the piece name, description, workflow structure, and optional movement previews.
* Returns the piece name, description, workflow structure, optional movement previews,
* piece-level interactive mode default, and first movement info for persona mode.
*/
export function getPieceDescription(
identifier: string,
projectCwd: string,
previewCount?: number,
): { name: string; description: string; pieceStructure: string; movementPreviews: MovementPreview[] } {
): {
name: string;
description: string;
pieceStructure: string;
movementPreviews: MovementPreview[];
interactiveMode?: InteractiveMode;
firstMovement?: FirstMovementInfo;
} {
const piece = loadPieceByIdentifier(identifier, projectCwd);
if (!piece) {
return { name: identifier, description: '', pieceStructure: '', movementPreviews: [] };
}
const previews = previewCount && previewCount > 0
? buildMovementPreviews(piece, previewCount)
: [];
const firstMovement = buildFirstMovementInfo(piece);
return {
name: piece.name,
description: piece.description ?? '',
pieceStructure: buildWorkflowString(piece.movements),
movementPreviews: previewCount && previewCount > 0
? buildMovementPreviews(piece, previewCount)
: [],
movementPreviews: previews,
interactiveMode: piece.interactiveMode,
firstMovement,
};
}
/**
* Build first movement info for persona mode.
* Reads persona content from the initial movement.
*/
function buildFirstMovementInfo(piece: PieceConfig): FirstMovementInfo | undefined {
const movement = piece.movements.find((m) => m.name === piece.initialMovement);
if (!movement) return undefined;
return {
personaContent: readMovementPersona(movement),
personaDisplayName: movement.personaDisplayName,
allowedTools: movement.allowedTools ?? [],
};
}

View File

@ -24,6 +24,17 @@ interactive:
continue: "Continue editing"
cancelled: "Cancelled"
playNoTask: "Please specify task content: /play <task>"
personaFallback: "No persona available for the first movement. Falling back to assistant mode."
modeSelection:
prompt: "Select interactive mode:"
assistant: "Assistant"
assistantDescription: "Ask clarifying questions before generating instructions"
persona: "Persona"
personaDescription: "Converse as the first agent's persona"
quiet: "Quiet"
quietDescription: "Generate instructions without asking questions"
passthrough: "Passthrough"
passthroughDescription: "Pass your input directly as task text"
previousTask:
success: "✅ Previous task completed successfully"
error: "❌ Previous task failed: {error}"

View File

@ -24,6 +24,17 @@ interactive:
continue: "会話を続ける"
cancelled: "キャンセルしました"
playNoTask: "タスク内容を指定してください: /play <タスク内容>"
personaFallback: "先頭ムーブメントにペルソナがありません。アシスタントモードにフォールバックします。"
modeSelection:
prompt: "対話モードを選択してください:"
assistant: "アシスタント"
assistantDescription: "確認質問をしてから指示書を作成"
persona: "ペルソナ"
personaDescription: "先頭エージェントのペルソナで対話"
quiet: "クワイエット"
quietDescription: "質問なしでベストエフォートの指示書を生成"
passthrough: "パススルー"
passthroughDescription: "入力をそのままタスクとして渡す"
previousTask:
success: "✅ 前回のタスクは正常に完了しました"
error: "❌ 前回のタスクはエラーで終了しました: {error}"