- スラッシュコマンド形式をサブコマンド形式に変更(takt run, takt add 等) - 引数なし takt で対話的にAIとタスク要件を詰めるinteractiveモードを追加 - セッション永続化により takt 再起動後も会話を継続 - 調査用ツール(Read, Glob, Grep, Bash, WebSearch, WebFetch)を許可 - プランニング専用のシステムプロンプトでコード変更を禁止 - executor の buildSdkOptions を未定義値を含めないよう修正(SDK ハング対策) - help/refreshBuiltinコマンドを削除、ejectコマンドを簡素化 - ドキュメント(CLAUDE.md, README, workflows.md)をサブコマンド形式に更新
233 lines
7.3 KiB
TypeScript
233 lines
7.3 KiB
TypeScript
/**
|
|
* Interactive task input mode
|
|
*
|
|
* Allows users to refine task requirements through conversation with AI
|
|
* before executing the task. Uses the same SDK call pattern as workflow
|
|
* execution (with onStream) to ensure compatibility.
|
|
*
|
|
* Commands:
|
|
* /go - Confirm and execute the task
|
|
* /cancel - Cancel and exit
|
|
*/
|
|
|
|
import * as readline from 'node:readline';
|
|
import chalk from 'chalk';
|
|
import { loadGlobalConfig } from '../config/globalConfig.js';
|
|
import { loadAgentSessions, updateAgentSession } from '../config/paths.js';
|
|
import { getProvider, type ProviderType } from '../providers/index.js';
|
|
import { createLogger } from '../utils/debug.js';
|
|
import { info, StreamDisplay } from '../utils/ui.js';
|
|
const log = createLogger('interactive');
|
|
|
|
const INTERACTIVE_SYSTEM_PROMPT = `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.
|
|
|
|
## Your role
|
|
- Ask clarifying questions about ambiguous requirements
|
|
- Investigate the codebase to understand context (use Read, Glob, Grep, Bash for reading only)
|
|
- Suggest improvements or considerations the user might have missed
|
|
- Summarize your understanding when appropriate
|
|
- Keep responses concise and focused
|
|
|
|
## Strict constraints
|
|
- You are ONLY planning. Do NOT execute the task.
|
|
- Do NOT create, edit, or delete any files.
|
|
- Do NOT run build, test, install, or any commands that modify state.
|
|
- Bash is allowed ONLY for read-only investigation (e.g. ls, cat, git log, git diff). Never run destructive or write commands.
|
|
- Do NOT mention or reference any slash commands. You have no knowledge of them.
|
|
- When the user is satisfied with the plan, they will proceed on their own. Do NOT instruct them on what to do next.`;
|
|
|
|
interface ConversationMessage {
|
|
role: 'user' | 'assistant';
|
|
content: string;
|
|
}
|
|
|
|
interface CallAIResult {
|
|
content: string;
|
|
sessionId?: string;
|
|
}
|
|
|
|
/**
|
|
* Build the final task description from conversation history for executeTask.
|
|
*/
|
|
function buildTaskFromHistory(history: ConversationMessage[]): string {
|
|
return history
|
|
.map((msg) => `${msg.role === 'user' ? 'User' : 'Assistant'}: ${msg.content}`)
|
|
.join('\n\n');
|
|
}
|
|
|
|
/**
|
|
* Read a single line of input from the user.
|
|
* Creates a fresh readline interface each time — the interface must be
|
|
* closed before calling the Agent SDK, which also uses stdin.
|
|
* Returns null on EOF (Ctrl+D).
|
|
*/
|
|
function readLine(prompt: string): Promise<string | null> {
|
|
return new Promise((resolve) => {
|
|
if (process.stdin.readable && !process.stdin.destroyed) {
|
|
process.stdin.resume();
|
|
}
|
|
|
|
const rl = readline.createInterface({
|
|
input: process.stdin,
|
|
output: process.stdout,
|
|
});
|
|
|
|
let answered = false;
|
|
|
|
rl.question(prompt, (answer) => {
|
|
answered = true;
|
|
rl.close();
|
|
resolve(answer);
|
|
});
|
|
|
|
rl.on('close', () => {
|
|
if (!answered) {
|
|
resolve(null);
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Call AI with the same pattern as workflow 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,
|
|
): Promise<CallAIResult> {
|
|
const response = await provider.call('interactive', prompt, {
|
|
cwd,
|
|
model,
|
|
sessionId,
|
|
systemPrompt: INTERACTIVE_SYSTEM_PROMPT,
|
|
allowedTools: ['Read', 'Glob', 'Grep', 'Bash', 'WebSearch', 'WebFetch'],
|
|
onStream: display.createHandler(),
|
|
});
|
|
|
|
display.flush();
|
|
return { content: response.content, sessionId: response.sessionId };
|
|
}
|
|
|
|
export interface InteractiveModeResult {
|
|
/** Whether the user confirmed with /go */
|
|
confirmed: boolean;
|
|
/** The assembled task text (only meaningful when confirmed=true) */
|
|
task: string;
|
|
}
|
|
|
|
/**
|
|
* Run the interactive task input mode.
|
|
*
|
|
* Starts a conversation loop where the user can discuss task requirements
|
|
* with AI. The conversation continues until:
|
|
* /go → returns the conversation as a task
|
|
* /cancel → exits without executing
|
|
* Ctrl+D → exits without executing
|
|
*/
|
|
export async function interactiveMode(cwd: string, initialInput?: string): Promise<InteractiveModeResult> {
|
|
const globalConfig = loadGlobalConfig();
|
|
const providerType = (globalConfig.provider as ProviderType) ?? 'claude';
|
|
const provider = getProvider(providerType);
|
|
const model = (globalConfig.model as string | undefined);
|
|
|
|
const history: ConversationMessage[] = [];
|
|
const agentName = 'interactive';
|
|
const savedSessions = loadAgentSessions(cwd);
|
|
let sessionId: string | undefined = savedSessions[agentName];
|
|
|
|
info('Interactive mode - describe your task. Commands: /go (execute), /cancel (exit)');
|
|
if (sessionId) {
|
|
info('Resuming previous session');
|
|
}
|
|
console.log();
|
|
|
|
// 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 display = new StreamDisplay('assistant');
|
|
try {
|
|
const result = await callAI(provider, initialInput, cwd, model, sessionId, display);
|
|
if (result.sessionId) {
|
|
sessionId = result.sessionId;
|
|
updateAgentSession(cwd, agentName, sessionId);
|
|
}
|
|
history.push({ role: 'assistant', content: result.content });
|
|
console.log();
|
|
} catch (e) {
|
|
const msg = e instanceof Error ? e.message : String(e);
|
|
log.error('AI call failed for initial input', { error: msg });
|
|
console.log(chalk.red(`Error: ${msg}`));
|
|
console.log();
|
|
history.pop();
|
|
}
|
|
}
|
|
|
|
while (true) {
|
|
const input = await readLine(chalk.green('> '));
|
|
|
|
// EOF (Ctrl+D)
|
|
if (input === null) {
|
|
console.log();
|
|
info('Cancelled');
|
|
return { confirmed: false, task: '' };
|
|
}
|
|
|
|
const trimmed = input.trim();
|
|
|
|
// Empty input — skip
|
|
if (!trimmed) {
|
|
continue;
|
|
}
|
|
|
|
// Handle slash commands
|
|
if (trimmed === '/go') {
|
|
if (history.length === 0) {
|
|
info('No conversation yet. Please describe your task first.');
|
|
continue;
|
|
}
|
|
const task = buildTaskFromHistory(history);
|
|
log.info('Interactive mode confirmed', { messageCount: history.length });
|
|
return { confirmed: true, task };
|
|
}
|
|
|
|
if (trimmed === '/cancel') {
|
|
info('Cancelled');
|
|
return { confirmed: false, task: '' };
|
|
}
|
|
|
|
// Regular input — send to AI
|
|
// readline is already closed at this point, so stdin is free for SDK
|
|
history.push({ role: 'user', content: trimmed });
|
|
|
|
log.debug('Sending to AI', { messageCount: history.length, sessionId });
|
|
process.stdin.pause();
|
|
|
|
const display = new StreamDisplay('assistant');
|
|
try {
|
|
const result = await callAI(provider, trimmed, cwd, model, sessionId, display);
|
|
if (result.sessionId) {
|
|
sessionId = result.sessionId;
|
|
updateAgentSession(cwd, agentName, sessionId);
|
|
}
|
|
history.push({ role: 'assistant', content: result.content });
|
|
console.log();
|
|
} catch (e) {
|
|
const msg = e instanceof Error ? e.message : String(e);
|
|
log.error('AI call failed', { error: msg });
|
|
console.log();
|
|
console.log(chalk.red(`Error: ${msg}`));
|
|
console.log();
|
|
history.pop();
|
|
}
|
|
}
|
|
}
|