構造化一歩目
This commit is contained in:
parent
482fa51266
commit
fc55bb2e0c
@ -1,185 +1,2 @@
|
|||||||
/**
|
/** Re-export shim — actual implementation in management/addTask.ts */
|
||||||
* add command implementation
|
export { addTask, summarizeConversation } from './management/addTask.js';
|
||||||
*
|
|
||||||
* Starts an AI conversation to refine task requirements,
|
|
||||||
* then creates a task file in .takt/tasks/ with YAML format.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import * as fs from 'node:fs';
|
|
||||||
import * as path from 'node:path';
|
|
||||||
import { stringify as stringifyYaml } from 'yaml';
|
|
||||||
import { promptInput, confirm, selectOption } from '../prompt/index.js';
|
|
||||||
import { success, info } from '../utils/ui.js';
|
|
||||||
import { summarizeTaskName } from '../task/summarize.js';
|
|
||||||
import { loadGlobalConfig } from '../config/globalConfig.js';
|
|
||||||
import { getProvider, type ProviderType } from '../providers/index.js';
|
|
||||||
import { createLogger } from '../utils/debug.js';
|
|
||||||
import { getErrorMessage } from '../utils/error.js';
|
|
||||||
import { listWorkflows } from '../config/workflowLoader.js';
|
|
||||||
import { getCurrentWorkflow } from '../config/paths.js';
|
|
||||||
import { interactiveMode } from './interactive.js';
|
|
||||||
import { isIssueReference, resolveIssueTask, parseIssueNumbers } from '../github/issue.js';
|
|
||||||
import type { TaskFileData } from '../task/schema.js';
|
|
||||||
|
|
||||||
const log = createLogger('add-task');
|
|
||||||
|
|
||||||
const SUMMARIZE_SYSTEM_PROMPT = `会話履歴からタスクの要件をまとめてください。
|
|
||||||
タスク実行エージェントへの指示として使われます。
|
|
||||||
具体的・簡潔に、必要な情報をすべて含めてください。
|
|
||||||
マークダウン形式で出力してください。`;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Summarize conversation history into a task description using AI.
|
|
||||||
*/
|
|
||||||
export async function summarizeConversation(cwd: string, conversationText: string): Promise<string> {
|
|
||||||
const globalConfig = loadGlobalConfig();
|
|
||||||
const providerType = (globalConfig.provider as ProviderType) ?? 'claude';
|
|
||||||
const provider = getProvider(providerType);
|
|
||||||
|
|
||||||
info('Summarizing task from conversation...');
|
|
||||||
|
|
||||||
const response = await provider.call('task-summarizer', conversationText, {
|
|
||||||
cwd,
|
|
||||||
maxTurns: 1,
|
|
||||||
allowedTools: [],
|
|
||||||
systemPrompt: SUMMARIZE_SYSTEM_PROMPT,
|
|
||||||
});
|
|
||||||
|
|
||||||
return response.content;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Generate a unique task filename with AI-summarized slug
|
|
||||||
*/
|
|
||||||
async function generateFilename(tasksDir: string, taskContent: string, cwd: string): Promise<string> {
|
|
||||||
info('Generating task filename...');
|
|
||||||
const slug = await summarizeTaskName(taskContent, { cwd });
|
|
||||||
const base = slug || 'task';
|
|
||||||
let filename = `${base}.yaml`;
|
|
||||||
let counter = 1;
|
|
||||||
|
|
||||||
while (fs.existsSync(path.join(tasksDir, filename))) {
|
|
||||||
filename = `${base}-${counter}.yaml`;
|
|
||||||
counter++;
|
|
||||||
}
|
|
||||||
|
|
||||||
return filename;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* add command handler
|
|
||||||
*
|
|
||||||
* Flow:
|
|
||||||
* 1. AI対話モードでタスクを詰める
|
|
||||||
* 2. 会話履歴からAIがタスク要約を生成
|
|
||||||
* 3. 要約からファイル名をAIで生成
|
|
||||||
* 4. ワークツリー/ブランチ/ワークフロー設定
|
|
||||||
* 5. YAMLファイル作成
|
|
||||||
*/
|
|
||||||
export async function addTask(cwd: string, task?: string): Promise<void> {
|
|
||||||
const tasksDir = path.join(cwd, '.takt', 'tasks');
|
|
||||||
fs.mkdirSync(tasksDir, { recursive: true });
|
|
||||||
|
|
||||||
let taskContent: string;
|
|
||||||
let issueNumber: number | undefined;
|
|
||||||
|
|
||||||
if (task && isIssueReference(task)) {
|
|
||||||
// Issue reference: fetch issue and use directly as task content
|
|
||||||
info('Fetching GitHub Issue...');
|
|
||||||
try {
|
|
||||||
taskContent = resolveIssueTask(task);
|
|
||||||
const numbers = parseIssueNumbers([task]);
|
|
||||||
if (numbers.length > 0) {
|
|
||||||
issueNumber = numbers[0];
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
const msg = getErrorMessage(e);
|
|
||||||
log.error('Failed to fetch GitHub Issue', { task, error: msg });
|
|
||||||
info(`Failed to fetch issue ${task}: ${msg}`);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Interactive mode: AI conversation to refine task
|
|
||||||
const result = await interactiveMode(cwd);
|
|
||||||
if (!result.confirmed) {
|
|
||||||
info('Cancelled.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 会話履歴からタスク要約を生成
|
|
||||||
taskContent = await summarizeConversation(cwd, result.task);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. 要約からファイル名生成
|
|
||||||
const firstLine = taskContent.split('\n')[0] || taskContent;
|
|
||||||
const filename = await generateFilename(tasksDir, firstLine, cwd);
|
|
||||||
|
|
||||||
// 4. ワークツリー/ブランチ/ワークフロー設定
|
|
||||||
let worktree: boolean | string | undefined;
|
|
||||||
let branch: string | undefined;
|
|
||||||
let workflow: string | undefined;
|
|
||||||
|
|
||||||
const useWorktree = await confirm('Create worktree?', true);
|
|
||||||
if (useWorktree) {
|
|
||||||
const customPath = await promptInput('Worktree path (Enter for auto)');
|
|
||||||
worktree = customPath || true;
|
|
||||||
|
|
||||||
const customBranch = await promptInput('Branch name (Enter for auto)');
|
|
||||||
if (customBranch) {
|
|
||||||
branch = customBranch;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const availableWorkflows = listWorkflows(cwd);
|
|
||||||
if (availableWorkflows.length > 0) {
|
|
||||||
const currentWorkflow = getCurrentWorkflow(cwd);
|
|
||||||
const defaultWorkflow = availableWorkflows.includes(currentWorkflow)
|
|
||||||
? currentWorkflow
|
|
||||||
: availableWorkflows[0]!;
|
|
||||||
const options = availableWorkflows.map((name) => ({
|
|
||||||
label: name === currentWorkflow ? `${name} (current)` : name,
|
|
||||||
value: name,
|
|
||||||
}));
|
|
||||||
const selected = await selectOption('Select workflow:', options);
|
|
||||||
if (selected === null) {
|
|
||||||
info('Cancelled.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (selected !== defaultWorkflow) {
|
|
||||||
workflow = selected;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 5. YAMLファイル作成
|
|
||||||
const taskData: TaskFileData = { task: taskContent };
|
|
||||||
if (worktree !== undefined) {
|
|
||||||
taskData.worktree = worktree;
|
|
||||||
}
|
|
||||||
if (branch) {
|
|
||||||
taskData.branch = branch;
|
|
||||||
}
|
|
||||||
if (workflow) {
|
|
||||||
taskData.workflow = workflow;
|
|
||||||
}
|
|
||||||
if (issueNumber !== undefined) {
|
|
||||||
taskData.issue = issueNumber;
|
|
||||||
}
|
|
||||||
|
|
||||||
const filePath = path.join(tasksDir, filename);
|
|
||||||
const yamlContent = stringifyYaml(taskData);
|
|
||||||
fs.writeFileSync(filePath, yamlContent, 'utf-8');
|
|
||||||
|
|
||||||
log.info('Task created', { filePath, taskData });
|
|
||||||
|
|
||||||
success(`Task created: ${filename}`);
|
|
||||||
info(` Path: ${filePath}`);
|
|
||||||
if (worktree) {
|
|
||||||
info(` Worktree: ${typeof worktree === 'string' ? worktree : 'auto'}`);
|
|
||||||
}
|
|
||||||
if (branch) {
|
|
||||||
info(` Branch: ${branch}`);
|
|
||||||
}
|
|
||||||
if (workflow) {
|
|
||||||
info(` Workflow: ${workflow}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,134 +1,2 @@
|
|||||||
/**
|
/** Re-export shim — actual implementation in management/config.ts */
|
||||||
* Config switching command (like workflow switching)
|
export { switchConfig, getCurrentPermissionMode, setPermissionMode, type PermissionMode } from './management/config.js';
|
||||||
*
|
|
||||||
* Permission mode selection that works from CLI.
|
|
||||||
* Uses selectOption for prompt selection, same pattern as switchWorkflow.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import chalk from 'chalk';
|
|
||||||
import { info, success } from '../utils/ui.js';
|
|
||||||
import { selectOption } from '../prompt/index.js';
|
|
||||||
import {
|
|
||||||
loadProjectConfig,
|
|
||||||
updateProjectConfig,
|
|
||||||
type PermissionMode,
|
|
||||||
} from '../config/projectConfig.js';
|
|
||||||
|
|
||||||
// Re-export for convenience
|
|
||||||
export type { PermissionMode } from '../config/projectConfig.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get permission mode options for selection
|
|
||||||
*/
|
|
||||||
/** Common permission mode option definitions */
|
|
||||||
export const PERMISSION_MODE_OPTIONS: {
|
|
||||||
key: PermissionMode;
|
|
||||||
label: string;
|
|
||||||
description: string;
|
|
||||||
details: string[];
|
|
||||||
icon: string;
|
|
||||||
}[] = [
|
|
||||||
{
|
|
||||||
key: 'default',
|
|
||||||
label: 'デフォルト (default)',
|
|
||||||
description: 'Agent SDK標準モード(ファイル編集自動承認、最小限の確認)',
|
|
||||||
details: [
|
|
||||||
'Claude Agent SDKの標準設定(acceptEdits)を使用',
|
|
||||||
'ファイル編集は自動承認され、確認プロンプトなしで実行',
|
|
||||||
'Bash等の危険な操作は権限確認が表示される',
|
|
||||||
'通常の開発作業に推奨',
|
|
||||||
],
|
|
||||||
icon: '📋',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
key: 'sacrifice-my-pc',
|
|
||||||
label: 'SACRIFICE-MY-PC',
|
|
||||||
description: '全ての権限リクエストが自動承認されます',
|
|
||||||
details: [
|
|
||||||
'⚠️ 警告: 全ての操作が確認なしで実行されます',
|
|
||||||
'Bash, ファイル削除, システム操作も自動承認',
|
|
||||||
'ブロック状態(判断待ち)も自動スキップ',
|
|
||||||
'完全自動化が必要な場合のみ使用してください',
|
|
||||||
],
|
|
||||||
icon: '💀',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
function getPermissionModeOptions(currentMode: PermissionMode): {
|
|
||||||
label: string;
|
|
||||||
value: PermissionMode;
|
|
||||||
description: string;
|
|
||||||
details: string[];
|
|
||||||
}[] {
|
|
||||||
return PERMISSION_MODE_OPTIONS.map((opt) => ({
|
|
||||||
label: currentMode === opt.key
|
|
||||||
? (opt.key === 'sacrifice-my-pc' ? chalk.red : chalk.blue)(`${opt.icon} ${opt.label}`) + ' (current)'
|
|
||||||
: (opt.key === 'sacrifice-my-pc' ? chalk.red : chalk.blue)(`${opt.icon} ${opt.label}`),
|
|
||||||
value: opt.key,
|
|
||||||
description: opt.description,
|
|
||||||
details: opt.details,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get current permission mode from project config
|
|
||||||
*/
|
|
||||||
export function getCurrentPermissionMode(cwd: string): PermissionMode {
|
|
||||||
const config = loadProjectConfig(cwd);
|
|
||||||
if (config.permissionMode) {
|
|
||||||
return config.permissionMode as PermissionMode;
|
|
||||||
}
|
|
||||||
return 'default';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set permission mode in project config
|
|
||||||
*/
|
|
||||||
export function setPermissionMode(cwd: string, mode: PermissionMode): void {
|
|
||||||
updateProjectConfig(cwd, 'permissionMode', mode);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Switch permission mode (like switchWorkflow)
|
|
||||||
* @returns true if switch was successful
|
|
||||||
*/
|
|
||||||
export async function switchConfig(cwd: string, modeName?: string): Promise<boolean> {
|
|
||||||
const currentMode = getCurrentPermissionMode(cwd);
|
|
||||||
|
|
||||||
// No mode specified - show selection prompt
|
|
||||||
if (!modeName) {
|
|
||||||
info(`Current mode: ${currentMode}`);
|
|
||||||
|
|
||||||
const options = getPermissionModeOptions(currentMode);
|
|
||||||
const selected = await selectOption('Select permission mode:', options);
|
|
||||||
|
|
||||||
if (!selected) {
|
|
||||||
info('Cancelled');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
modeName = selected;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate mode name
|
|
||||||
if (modeName !== 'default' && modeName !== 'sacrifice-my-pc') {
|
|
||||||
info(`Invalid mode: ${modeName}`);
|
|
||||||
info('Available modes: default, sacrifice-my-pc');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
const finalMode: PermissionMode = modeName as PermissionMode;
|
|
||||||
|
|
||||||
// Save to project config
|
|
||||||
setPermissionMode(cwd, finalMode);
|
|
||||||
|
|
||||||
if (finalMode === 'sacrifice-my-pc') {
|
|
||||||
success('Switched to: sacrifice-my-pc 💀');
|
|
||||||
info('All permission requests will be auto-approved.');
|
|
||||||
} else {
|
|
||||||
success('Switched to: default 📋');
|
|
||||||
info('Using Agent SDK default mode (acceptEdits - minimal permission prompts).');
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,124 +1,2 @@
|
|||||||
/**
|
/** Re-export shim — actual implementation in management/eject.ts */
|
||||||
* /eject command implementation
|
export { ejectBuiltin } from './management/eject.js';
|
||||||
*
|
|
||||||
* Copies a builtin workflow (and its agents) to ~/.takt/ for user customization.
|
|
||||||
* Once ejected, the user copy takes priority over the builtin version.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { existsSync, readdirSync, statSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
|
||||||
import { join, dirname } from 'node:path';
|
|
||||||
import { getGlobalWorkflowsDir, getGlobalAgentsDir, getBuiltinWorkflowsDir, getBuiltinAgentsDir } from '../config/paths.js';
|
|
||||||
import { getLanguage } from '../config/globalConfig.js';
|
|
||||||
import { header, success, info, warn, error, blankLine } from '../utils/ui.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Eject a builtin workflow to user space for customization.
|
|
||||||
* Copies the workflow YAML and related agent .md files to ~/.takt/.
|
|
||||||
* Agent paths in the ejected workflow are rewritten from ../agents/ to ~/.takt/agents/.
|
|
||||||
*/
|
|
||||||
export async function ejectBuiltin(name?: string): Promise<void> {
|
|
||||||
header('Eject Builtin');
|
|
||||||
|
|
||||||
const lang = getLanguage();
|
|
||||||
const builtinWorkflowsDir = getBuiltinWorkflowsDir(lang);
|
|
||||||
|
|
||||||
if (!name) {
|
|
||||||
// List available builtins
|
|
||||||
listAvailableBuiltins(builtinWorkflowsDir);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const builtinPath = join(builtinWorkflowsDir, `${name}.yaml`);
|
|
||||||
if (!existsSync(builtinPath)) {
|
|
||||||
error(`Builtin workflow not found: ${name}`);
|
|
||||||
info('Run "takt eject" to see available builtins.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const userWorkflowsDir = getGlobalWorkflowsDir();
|
|
||||||
const userAgentsDir = getGlobalAgentsDir();
|
|
||||||
const builtinAgentsDir = getBuiltinAgentsDir(lang);
|
|
||||||
|
|
||||||
// Copy workflow YAML (rewrite agent paths)
|
|
||||||
const workflowDest = join(userWorkflowsDir, `${name}.yaml`);
|
|
||||||
if (existsSync(workflowDest)) {
|
|
||||||
warn(`User workflow already exists: ${workflowDest}`);
|
|
||||||
warn('Skipping workflow copy (user version takes priority).');
|
|
||||||
} else {
|
|
||||||
mkdirSync(dirname(workflowDest), { recursive: true });
|
|
||||||
const content = readFileSync(builtinPath, 'utf-8');
|
|
||||||
// Rewrite relative agent paths to ~/.takt/agents/
|
|
||||||
const rewritten = content.replace(
|
|
||||||
/agent:\s*\.\.\/agents\//g,
|
|
||||||
'agent: ~/.takt/agents/',
|
|
||||||
);
|
|
||||||
writeFileSync(workflowDest, rewritten, 'utf-8');
|
|
||||||
success(`Ejected workflow: ${workflowDest}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Copy related agent files
|
|
||||||
const agentPaths = extractAgentRelativePaths(builtinPath);
|
|
||||||
let copiedAgents = 0;
|
|
||||||
|
|
||||||
for (const relPath of agentPaths) {
|
|
||||||
const srcPath = join(builtinAgentsDir, relPath);
|
|
||||||
const destPath = join(userAgentsDir, relPath);
|
|
||||||
|
|
||||||
if (!existsSync(srcPath)) continue;
|
|
||||||
|
|
||||||
if (existsSync(destPath)) {
|
|
||||||
info(` Agent already exists: ${destPath}`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
mkdirSync(dirname(destPath), { recursive: true });
|
|
||||||
writeFileSync(destPath, readFileSync(srcPath));
|
|
||||||
info(` ✓ ${destPath}`);
|
|
||||||
copiedAgents++;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (copiedAgents > 0) {
|
|
||||||
success(`${copiedAgents} agent file(s) ejected.`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** List available builtin workflows for ejection */
|
|
||||||
function listAvailableBuiltins(builtinWorkflowsDir: string): void {
|
|
||||||
if (!existsSync(builtinWorkflowsDir)) {
|
|
||||||
warn('No builtin workflows found.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
info('Available builtin workflows:');
|
|
||||||
blankLine();
|
|
||||||
|
|
||||||
for (const entry of readdirSync(builtinWorkflowsDir).sort()) {
|
|
||||||
if (!entry.endsWith('.yaml') && !entry.endsWith('.yml')) continue;
|
|
||||||
if (!statSync(join(builtinWorkflowsDir, entry)).isFile()) continue;
|
|
||||||
|
|
||||||
const name = entry.replace(/\.ya?ml$/, '');
|
|
||||||
info(` ${name}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
blankLine();
|
|
||||||
info('Usage: takt eject {name}');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract agent relative paths from a builtin workflow YAML.
|
|
||||||
* Matches `agent: ../agents/{path}` and returns the {path} portions.
|
|
||||||
*/
|
|
||||||
function extractAgentRelativePaths(workflowPath: string): string[] {
|
|
||||||
const content = readFileSync(workflowPath, 'utf-8');
|
|
||||||
const paths = new Set<string>();
|
|
||||||
const regex = /agent:\s*\.\.\/agents\/(.+)/g;
|
|
||||||
|
|
||||||
let match: RegExpExecArray | null;
|
|
||||||
while ((match = regex.exec(content)) !== null) {
|
|
||||||
if (match[1]) {
|
|
||||||
paths.add(match[1].trim());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Array.from(paths);
|
|
||||||
}
|
|
||||||
|
|||||||
14
src/commands/execution/index.ts
Normal file
14
src/commands/execution/index.ts
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
/**
|
||||||
|
* Task/workflow execution commands.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { executeWorkflow, type WorkflowExecutionResult, type WorkflowExecutionOptions } from './workflowExecution.js';
|
||||||
|
export { executeTask, runAllTasks, executeAndCompleteTask, resolveTaskExecution, type TaskExecutionOptions } from './taskExecution.js';
|
||||||
|
export {
|
||||||
|
selectAndExecuteTask,
|
||||||
|
confirmAndCreateWorktree,
|
||||||
|
type SelectAndExecuteOptions,
|
||||||
|
type WorktreeConfirmationResult,
|
||||||
|
} from './selectAndExecute.js';
|
||||||
|
export { executePipeline, type PipelineExecutionOptions } from './pipelineExecution.js';
|
||||||
|
export { withAgentSession } from './session.js';
|
||||||
243
src/commands/execution/pipelineExecution.ts
Normal file
243
src/commands/execution/pipelineExecution.ts
Normal file
@ -0,0 +1,243 @@
|
|||||||
|
/**
|
||||||
|
* Pipeline execution flow
|
||||||
|
*
|
||||||
|
* Orchestrates the full pipeline:
|
||||||
|
* 1. Fetch issue content
|
||||||
|
* 2. Create branch
|
||||||
|
* 3. Run workflow
|
||||||
|
* 4. Commit & push
|
||||||
|
* 5. Create PR
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { execFileSync } from 'node:child_process';
|
||||||
|
import { fetchIssue, formatIssueAsTask, checkGhCli, type GitHubIssue } from '../../github/issue.js';
|
||||||
|
import { createPullRequest, pushBranch, buildPrBody } from '../../github/pr.js';
|
||||||
|
import { stageAndCommit } from '../../task/git.js';
|
||||||
|
import { executeTask, type TaskExecutionOptions } from '../taskExecution.js';
|
||||||
|
import { loadGlobalConfig } from '../../config/globalConfig.js';
|
||||||
|
import { info, error, success, status, blankLine } from '../../utils/ui.js';
|
||||||
|
import { createLogger } from '../../utils/debug.js';
|
||||||
|
import { getErrorMessage } from '../../utils/error.js';
|
||||||
|
import type { PipelineConfig } from '../../models/types.js';
|
||||||
|
import {
|
||||||
|
EXIT_ISSUE_FETCH_FAILED,
|
||||||
|
EXIT_WORKFLOW_FAILED,
|
||||||
|
EXIT_GIT_OPERATION_FAILED,
|
||||||
|
EXIT_PR_CREATION_FAILED,
|
||||||
|
} from '../../exitCodes.js';
|
||||||
|
import type { ProviderType } from '../../providers/index.js';
|
||||||
|
|
||||||
|
const log = createLogger('pipeline');
|
||||||
|
|
||||||
|
export interface PipelineExecutionOptions {
|
||||||
|
/** GitHub issue number */
|
||||||
|
issueNumber?: number;
|
||||||
|
/** Task content (alternative to issue) */
|
||||||
|
task?: string;
|
||||||
|
/** Workflow name or path to workflow file */
|
||||||
|
workflow: string;
|
||||||
|
/** Branch name (auto-generated if omitted) */
|
||||||
|
branch?: string;
|
||||||
|
/** Whether to create a PR after successful execution */
|
||||||
|
autoPr: boolean;
|
||||||
|
/** Repository in owner/repo format */
|
||||||
|
repo?: string;
|
||||||
|
/** Skip branch creation, commit, and push (workflow-only execution) */
|
||||||
|
skipGit?: boolean;
|
||||||
|
/** Working directory */
|
||||||
|
cwd: string;
|
||||||
|
provider?: ProviderType;
|
||||||
|
model?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Expand template variables in a string.
|
||||||
|
* Supported: {title}, {issue}, {issue_body}, {report}
|
||||||
|
*/
|
||||||
|
function expandTemplate(template: string, vars: Record<string, string>): string {
|
||||||
|
return template.replace(/\{(\w+)\}/g, (match, key: string) => vars[key] ?? match);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Generate a branch name for pipeline execution */
|
||||||
|
function generatePipelineBranchName(pipelineConfig: PipelineConfig | undefined, issueNumber?: number): string {
|
||||||
|
const prefix = pipelineConfig?.defaultBranchPrefix ?? 'takt/';
|
||||||
|
const timestamp = Math.floor(Date.now() / 1000);
|
||||||
|
if (issueNumber) {
|
||||||
|
return `${prefix}issue-${issueNumber}-${timestamp}`;
|
||||||
|
}
|
||||||
|
return `${prefix}pipeline-${timestamp}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create and checkout a new branch */
|
||||||
|
function createBranch(cwd: string, branch: string): void {
|
||||||
|
execFileSync('git', ['checkout', '-b', branch], {
|
||||||
|
cwd,
|
||||||
|
stdio: 'pipe',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build commit message from template or defaults */
|
||||||
|
function buildCommitMessage(
|
||||||
|
pipelineConfig: PipelineConfig | undefined,
|
||||||
|
issue: GitHubIssue | undefined,
|
||||||
|
taskText: string | undefined,
|
||||||
|
): string {
|
||||||
|
const template = pipelineConfig?.commitMessageTemplate;
|
||||||
|
if (template && issue) {
|
||||||
|
return expandTemplate(template, {
|
||||||
|
title: issue.title,
|
||||||
|
issue: String(issue.number),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Default commit message
|
||||||
|
return issue
|
||||||
|
? `feat: ${issue.title} (#${issue.number})`
|
||||||
|
: `takt: ${taskText ?? 'pipeline task'}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build PR body from template or defaults */
|
||||||
|
function buildPipelinePrBody(
|
||||||
|
pipelineConfig: PipelineConfig | undefined,
|
||||||
|
issue: GitHubIssue | undefined,
|
||||||
|
report: string,
|
||||||
|
): string {
|
||||||
|
const template = pipelineConfig?.prBodyTemplate;
|
||||||
|
if (template && issue) {
|
||||||
|
return expandTemplate(template, {
|
||||||
|
title: issue.title,
|
||||||
|
issue: String(issue.number),
|
||||||
|
issue_body: issue.body || issue.title,
|
||||||
|
report,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return buildPrBody(issue, report);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute the full pipeline.
|
||||||
|
*
|
||||||
|
* Returns a process exit code (0 on success, 2-5 on specific failures).
|
||||||
|
*/
|
||||||
|
export async function executePipeline(options: PipelineExecutionOptions): Promise<number> {
|
||||||
|
const { cwd, workflow, autoPr, skipGit } = options;
|
||||||
|
const globalConfig = loadGlobalConfig();
|
||||||
|
const pipelineConfig = globalConfig.pipeline;
|
||||||
|
let issue: GitHubIssue | undefined;
|
||||||
|
let task: string;
|
||||||
|
|
||||||
|
// --- Step 1: Resolve task content ---
|
||||||
|
if (options.issueNumber) {
|
||||||
|
info(`Fetching issue #${options.issueNumber}...`);
|
||||||
|
try {
|
||||||
|
const ghStatus = checkGhCli();
|
||||||
|
if (!ghStatus.available) {
|
||||||
|
error(ghStatus.error ?? 'gh CLI is not available');
|
||||||
|
return EXIT_ISSUE_FETCH_FAILED;
|
||||||
|
}
|
||||||
|
issue = fetchIssue(options.issueNumber);
|
||||||
|
task = formatIssueAsTask(issue);
|
||||||
|
success(`Issue #${options.issueNumber} fetched: "${issue.title}"`);
|
||||||
|
} catch (err) {
|
||||||
|
error(`Failed to fetch issue #${options.issueNumber}: ${getErrorMessage(err)}`);
|
||||||
|
return EXIT_ISSUE_FETCH_FAILED;
|
||||||
|
}
|
||||||
|
} else if (options.task) {
|
||||||
|
task = options.task;
|
||||||
|
} else {
|
||||||
|
error('Either --issue or --task must be specified');
|
||||||
|
return EXIT_ISSUE_FETCH_FAILED;
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Step 2: Create branch (skip if --skip-git) ---
|
||||||
|
let branch: string | undefined;
|
||||||
|
if (!skipGit) {
|
||||||
|
branch = options.branch ?? generatePipelineBranchName(pipelineConfig, options.issueNumber);
|
||||||
|
info(`Creating branch: ${branch}`);
|
||||||
|
try {
|
||||||
|
createBranch(cwd, branch);
|
||||||
|
success(`Branch created: ${branch}`);
|
||||||
|
} catch (err) {
|
||||||
|
error(`Failed to create branch: ${getErrorMessage(err)}`);
|
||||||
|
return EXIT_GIT_OPERATION_FAILED;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Step 3: Run workflow ---
|
||||||
|
info(`Running workflow: ${workflow}`);
|
||||||
|
log.info('Pipeline workflow execution starting', { workflow, branch, skipGit, issueNumber: options.issueNumber });
|
||||||
|
|
||||||
|
const agentOverrides: TaskExecutionOptions | undefined = (options.provider || options.model)
|
||||||
|
? { provider: options.provider, model: options.model }
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const taskSuccess = await executeTask({
|
||||||
|
task,
|
||||||
|
cwd,
|
||||||
|
workflowIdentifier: workflow,
|
||||||
|
projectCwd: cwd,
|
||||||
|
agentOverrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!taskSuccess) {
|
||||||
|
error(`Workflow '${workflow}' failed`);
|
||||||
|
return EXIT_WORKFLOW_FAILED;
|
||||||
|
}
|
||||||
|
success(`Workflow '${workflow}' completed`);
|
||||||
|
|
||||||
|
// --- Step 4: Commit & push (skip if --skip-git) ---
|
||||||
|
if (!skipGit && branch) {
|
||||||
|
const commitMessage = buildCommitMessage(pipelineConfig, issue, options.task);
|
||||||
|
|
||||||
|
info('Committing changes...');
|
||||||
|
try {
|
||||||
|
const commitHash = stageAndCommit(cwd, commitMessage);
|
||||||
|
if (commitHash) {
|
||||||
|
success(`Changes committed: ${commitHash}`);
|
||||||
|
} else {
|
||||||
|
info('No changes to commit');
|
||||||
|
}
|
||||||
|
|
||||||
|
info(`Pushing to origin/${branch}...`);
|
||||||
|
pushBranch(cwd, branch);
|
||||||
|
success(`Pushed to origin/${branch}`);
|
||||||
|
} catch (err) {
|
||||||
|
error(`Git operation failed: ${getErrorMessage(err)}`);
|
||||||
|
return EXIT_GIT_OPERATION_FAILED;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Step 5: Create PR (if --auto-pr) ---
|
||||||
|
if (autoPr) {
|
||||||
|
if (skipGit) {
|
||||||
|
info('--auto-pr is ignored when --skip-git is specified (no push was performed)');
|
||||||
|
} else if (branch) {
|
||||||
|
info('Creating pull request...');
|
||||||
|
const prTitle = issue ? issue.title : (options.task ?? 'Pipeline task');
|
||||||
|
const report = `Workflow \`${workflow}\` completed successfully.`;
|
||||||
|
const prBody = buildPipelinePrBody(pipelineConfig, issue, report);
|
||||||
|
|
||||||
|
const prResult = createPullRequest(cwd, {
|
||||||
|
branch,
|
||||||
|
title: prTitle,
|
||||||
|
body: prBody,
|
||||||
|
repo: options.repo,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (prResult.success) {
|
||||||
|
success(`PR created: ${prResult.url}`);
|
||||||
|
} else {
|
||||||
|
error(`PR creation failed: ${prResult.error}`);
|
||||||
|
return EXIT_PR_CREATION_FAILED;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Summary ---
|
||||||
|
blankLine();
|
||||||
|
status('Issue', issue ? `#${issue.number} "${issue.title}"` : 'N/A');
|
||||||
|
status('Branch', branch ?? '(current)');
|
||||||
|
status('Workflow', workflow);
|
||||||
|
status('Result', 'Success', 'green');
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
184
src/commands/execution/selectAndExecute.ts
Normal file
184
src/commands/execution/selectAndExecute.ts
Normal file
@ -0,0 +1,184 @@
|
|||||||
|
/**
|
||||||
|
* Task execution orchestration.
|
||||||
|
*
|
||||||
|
* Coordinates workflow selection, worktree creation, task execution,
|
||||||
|
* auto-commit, and PR creation. Extracted from cli.ts to avoid
|
||||||
|
* mixing CLI parsing with business logic.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { getCurrentWorkflow } from '../../config/paths.js';
|
||||||
|
import { listWorkflows, isWorkflowPath } from '../../config/workflowLoader.js';
|
||||||
|
import { selectOptionWithDefault, confirm } from '../../prompt/index.js';
|
||||||
|
import { createSharedClone } from '../../task/clone.js';
|
||||||
|
import { autoCommitAndPush } from '../../task/autoCommit.js';
|
||||||
|
import { summarizeTaskName } from '../../task/summarize.js';
|
||||||
|
import { DEFAULT_WORKFLOW_NAME } from '../../constants.js';
|
||||||
|
import { info, error, success } from '../../utils/ui.js';
|
||||||
|
import { createLogger } from '../../utils/debug.js';
|
||||||
|
import { createPullRequest, buildPrBody } from '../../github/pr.js';
|
||||||
|
import { executeTask } from '../taskExecution.js';
|
||||||
|
import type { TaskExecutionOptions } from '../taskExecution.js';
|
||||||
|
|
||||||
|
const log = createLogger('selectAndExecute');
|
||||||
|
|
||||||
|
export interface WorktreeConfirmationResult {
|
||||||
|
execCwd: string;
|
||||||
|
isWorktree: boolean;
|
||||||
|
branch?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SelectAndExecuteOptions {
|
||||||
|
autoPr?: boolean;
|
||||||
|
repo?: string;
|
||||||
|
workflow?: string;
|
||||||
|
createWorktree?: boolean | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Select a workflow interactively.
|
||||||
|
* Returns the selected workflow name, or null if cancelled.
|
||||||
|
*/
|
||||||
|
async function selectWorkflow(cwd: string): Promise<string | null> {
|
||||||
|
const availableWorkflows = listWorkflows(cwd);
|
||||||
|
const currentWorkflow = getCurrentWorkflow(cwd);
|
||||||
|
|
||||||
|
if (availableWorkflows.length === 0) {
|
||||||
|
info(`No workflows found. Using default: ${DEFAULT_WORKFLOW_NAME}`);
|
||||||
|
return DEFAULT_WORKFLOW_NAME;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (availableWorkflows.length === 1 && availableWorkflows[0]) {
|
||||||
|
return availableWorkflows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
const options = availableWorkflows.map((name) => ({
|
||||||
|
label: name === currentWorkflow ? `${name} (current)` : name,
|
||||||
|
value: name,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const defaultWorkflow = availableWorkflows.includes(currentWorkflow)
|
||||||
|
? currentWorkflow
|
||||||
|
: (availableWorkflows.includes(DEFAULT_WORKFLOW_NAME)
|
||||||
|
? DEFAULT_WORKFLOW_NAME
|
||||||
|
: availableWorkflows[0] || DEFAULT_WORKFLOW_NAME);
|
||||||
|
|
||||||
|
return selectOptionWithDefault('Select workflow:', options, defaultWorkflow);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine workflow to use.
|
||||||
|
*
|
||||||
|
* - If override looks like a path (isWorkflowPath), return it directly (validation is done at load time).
|
||||||
|
* - If override is a name, validate it exists in available workflows.
|
||||||
|
* - If no override, prompt user to select interactively.
|
||||||
|
*/
|
||||||
|
async function determineWorkflow(cwd: string, override?: string): Promise<string | null> {
|
||||||
|
if (override) {
|
||||||
|
// Path-based: skip name validation (loader handles existence check)
|
||||||
|
if (isWorkflowPath(override)) {
|
||||||
|
return override;
|
||||||
|
}
|
||||||
|
// Name-based: validate workflow name exists
|
||||||
|
const availableWorkflows = listWorkflows(cwd);
|
||||||
|
const knownWorkflows = availableWorkflows.length === 0 ? [DEFAULT_WORKFLOW_NAME] : availableWorkflows;
|
||||||
|
if (!knownWorkflows.includes(override)) {
|
||||||
|
error(`Workflow not found: ${override}`);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return override;
|
||||||
|
}
|
||||||
|
return selectWorkflow(cwd);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function confirmAndCreateWorktree(
|
||||||
|
cwd: string,
|
||||||
|
task: string,
|
||||||
|
createWorktreeOverride?: boolean | undefined,
|
||||||
|
): Promise<WorktreeConfirmationResult> {
|
||||||
|
const useWorktree =
|
||||||
|
typeof createWorktreeOverride === 'boolean'
|
||||||
|
? createWorktreeOverride
|
||||||
|
: await confirm('Create worktree?', true);
|
||||||
|
|
||||||
|
if (!useWorktree) {
|
||||||
|
return { execCwd: cwd, isWorktree: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Summarize task name to English slug using AI
|
||||||
|
info('Generating branch name...');
|
||||||
|
const taskSlug = await summarizeTaskName(task, { cwd });
|
||||||
|
|
||||||
|
const result = createSharedClone(cwd, {
|
||||||
|
worktree: true,
|
||||||
|
taskSlug,
|
||||||
|
});
|
||||||
|
info(`Clone created: ${result.path} (branch: ${result.branch})`);
|
||||||
|
|
||||||
|
return { execCwd: result.path, isWorktree: true, branch: result.branch };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a task with workflow selection, optional worktree, and auto-commit.
|
||||||
|
* Shared by direct task execution and interactive mode.
|
||||||
|
*/
|
||||||
|
export async function selectAndExecuteTask(
|
||||||
|
cwd: string,
|
||||||
|
task: string,
|
||||||
|
options?: SelectAndExecuteOptions,
|
||||||
|
agentOverrides?: TaskExecutionOptions,
|
||||||
|
): Promise<void> {
|
||||||
|
const workflowIdentifier = await determineWorkflow(cwd, options?.workflow);
|
||||||
|
|
||||||
|
if (workflowIdentifier === null) {
|
||||||
|
info('Cancelled');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { execCwd, isWorktree, branch } = await confirmAndCreateWorktree(
|
||||||
|
cwd,
|
||||||
|
task,
|
||||||
|
options?.createWorktree,
|
||||||
|
);
|
||||||
|
|
||||||
|
log.info('Starting task execution', { workflow: workflowIdentifier, worktree: isWorktree });
|
||||||
|
const taskSuccess = await executeTask({
|
||||||
|
task,
|
||||||
|
cwd: execCwd,
|
||||||
|
workflowIdentifier,
|
||||||
|
projectCwd: cwd,
|
||||||
|
agentOverrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (taskSuccess && isWorktree) {
|
||||||
|
const commitResult = autoCommitAndPush(execCwd, task, cwd);
|
||||||
|
if (commitResult.success && commitResult.commitHash) {
|
||||||
|
success(`Auto-committed & pushed: ${commitResult.commitHash}`);
|
||||||
|
} else if (!commitResult.success) {
|
||||||
|
error(`Auto-commit failed: ${commitResult.message}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// PR creation: --auto-pr → create automatically, otherwise ask
|
||||||
|
if (commitResult.success && commitResult.commitHash && branch) {
|
||||||
|
const shouldCreatePr = options?.autoPr === true || await confirm('Create pull request?', false);
|
||||||
|
if (shouldCreatePr) {
|
||||||
|
info('Creating pull request...');
|
||||||
|
const prBody = buildPrBody(undefined, `Workflow \`${workflowIdentifier}\` completed successfully.`);
|
||||||
|
const prResult = createPullRequest(execCwd, {
|
||||||
|
branch,
|
||||||
|
title: task.length > 100 ? `${task.slice(0, 97)}...` : task,
|
||||||
|
body: prBody,
|
||||||
|
repo: options?.repo,
|
||||||
|
});
|
||||||
|
if (prResult.success) {
|
||||||
|
success(`PR created: ${prResult.url}`);
|
||||||
|
} else {
|
||||||
|
error(`PR creation failed: ${prResult.error}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!taskSuccess) {
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
30
src/commands/execution/session.ts
Normal file
30
src/commands/execution/session.ts
Normal file
@ -0,0 +1,30 @@
|
|||||||
|
/**
|
||||||
|
* Session management helpers for agent execution
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { loadAgentSessions, updateAgentSession } from '../../config/paths.js';
|
||||||
|
import { loadGlobalConfig } from '../../config/globalConfig.js';
|
||||||
|
import type { AgentResponse } from '../../models/types.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a function with agent session management.
|
||||||
|
* Automatically loads existing session and saves updated session ID.
|
||||||
|
*/
|
||||||
|
export async function withAgentSession(
|
||||||
|
cwd: string,
|
||||||
|
agentName: string,
|
||||||
|
fn: (sessionId?: string) => Promise<AgentResponse>,
|
||||||
|
provider?: string
|
||||||
|
): Promise<AgentResponse> {
|
||||||
|
const resolvedProvider = provider ?? loadGlobalConfig().provider ?? 'claude';
|
||||||
|
const sessions = loadAgentSessions(cwd, resolvedProvider);
|
||||||
|
const sessionId = sessions[agentName];
|
||||||
|
|
||||||
|
const result = await fn(sessionId);
|
||||||
|
|
||||||
|
if (result.sessionId) {
|
||||||
|
updateAgentSession(cwd, agentName, result.sessionId, resolvedProvider);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
249
src/commands/execution/taskExecution.ts
Normal file
249
src/commands/execution/taskExecution.ts
Normal file
@ -0,0 +1,249 @@
|
|||||||
|
/**
|
||||||
|
* Task execution logic
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { loadWorkflowByIdentifier, isWorkflowPath, loadGlobalConfig } from '../../config/index.js';
|
||||||
|
import { TaskRunner, type TaskInfo } from '../../task/index.js';
|
||||||
|
import { createSharedClone } from '../../task/clone.js';
|
||||||
|
import { autoCommitAndPush } from '../../task/autoCommit.js';
|
||||||
|
import { summarizeTaskName } from '../../task/summarize.js';
|
||||||
|
import {
|
||||||
|
header,
|
||||||
|
info,
|
||||||
|
error,
|
||||||
|
success,
|
||||||
|
status,
|
||||||
|
blankLine,
|
||||||
|
} from '../../utils/ui.js';
|
||||||
|
import { createLogger } from '../../utils/debug.js';
|
||||||
|
import { getErrorMessage } from '../../utils/error.js';
|
||||||
|
import { executeWorkflow } from '../workflowExecution.js';
|
||||||
|
import { DEFAULT_WORKFLOW_NAME } from '../../constants.js';
|
||||||
|
import type { ProviderType } from '../../providers/index.js';
|
||||||
|
|
||||||
|
const log = createLogger('task');
|
||||||
|
|
||||||
|
export interface TaskExecutionOptions {
|
||||||
|
provider?: ProviderType;
|
||||||
|
model?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ExecuteTaskOptions {
|
||||||
|
/** Task content */
|
||||||
|
task: string;
|
||||||
|
/** Working directory (may be a clone path) */
|
||||||
|
cwd: string;
|
||||||
|
/** Workflow name or path (auto-detected by isWorkflowPath) */
|
||||||
|
workflowIdentifier: string;
|
||||||
|
/** Project root (where .takt/ lives) */
|
||||||
|
projectCwd: string;
|
||||||
|
/** Agent provider/model overrides */
|
||||||
|
agentOverrides?: TaskExecutionOptions;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a single task with workflow.
|
||||||
|
*/
|
||||||
|
export async function executeTask(options: ExecuteTaskOptions): Promise<boolean> {
|
||||||
|
const { task, cwd, workflowIdentifier, projectCwd, agentOverrides } = options;
|
||||||
|
const workflowConfig = loadWorkflowByIdentifier(workflowIdentifier, projectCwd);
|
||||||
|
|
||||||
|
if (!workflowConfig) {
|
||||||
|
if (isWorkflowPath(workflowIdentifier)) {
|
||||||
|
error(`Workflow file not found: ${workflowIdentifier}`);
|
||||||
|
} else {
|
||||||
|
error(`Workflow "${workflowIdentifier}" not found.`);
|
||||||
|
info('Available workflows are in ~/.takt/workflows/ or .takt/workflows/');
|
||||||
|
info('Use "takt switch" to select a workflow.');
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
log.debug('Running workflow', {
|
||||||
|
name: workflowConfig.name,
|
||||||
|
steps: workflowConfig.steps.map((s: { name: string }) => s.name),
|
||||||
|
});
|
||||||
|
|
||||||
|
const globalConfig = loadGlobalConfig();
|
||||||
|
const result = await executeWorkflow(workflowConfig, task, cwd, {
|
||||||
|
projectCwd,
|
||||||
|
language: globalConfig.language,
|
||||||
|
provider: agentOverrides?.provider,
|
||||||
|
model: agentOverrides?.model,
|
||||||
|
});
|
||||||
|
return result.success;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a task: resolve clone → run workflow → auto-commit+push → remove clone → record completion.
|
||||||
|
*
|
||||||
|
* Shared by runAllTasks() and watchTasks() to avoid duplicated
|
||||||
|
* resolve → execute → autoCommit → complete logic.
|
||||||
|
*
|
||||||
|
* @returns true if the task succeeded
|
||||||
|
*/
|
||||||
|
export async function executeAndCompleteTask(
|
||||||
|
task: TaskInfo,
|
||||||
|
taskRunner: TaskRunner,
|
||||||
|
cwd: string,
|
||||||
|
workflowName: string,
|
||||||
|
options?: TaskExecutionOptions,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const startedAt = new Date().toISOString();
|
||||||
|
const executionLog: string[] = [];
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { execCwd, execWorkflow, isWorktree } = await resolveTaskExecution(task, cwd, workflowName);
|
||||||
|
|
||||||
|
// cwd is always the project root; pass it as projectCwd so reports/sessions go there
|
||||||
|
const taskSuccess = await executeTask({
|
||||||
|
task: task.content,
|
||||||
|
cwd: execCwd,
|
||||||
|
workflowIdentifier: execWorkflow,
|
||||||
|
projectCwd: cwd,
|
||||||
|
agentOverrides: options,
|
||||||
|
});
|
||||||
|
const completedAt = new Date().toISOString();
|
||||||
|
|
||||||
|
if (taskSuccess && isWorktree) {
|
||||||
|
const commitResult = autoCommitAndPush(execCwd, task.name, cwd);
|
||||||
|
if (commitResult.success && commitResult.commitHash) {
|
||||||
|
info(`Auto-committed & pushed: ${commitResult.commitHash}`);
|
||||||
|
} else if (!commitResult.success) {
|
||||||
|
error(`Auto-commit failed: ${commitResult.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const taskResult = {
|
||||||
|
task,
|
||||||
|
success: taskSuccess,
|
||||||
|
response: taskSuccess ? 'Task completed successfully' : 'Task failed',
|
||||||
|
executionLog,
|
||||||
|
startedAt,
|
||||||
|
completedAt,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (taskSuccess) {
|
||||||
|
taskRunner.completeTask(taskResult);
|
||||||
|
success(`Task "${task.name}" completed`);
|
||||||
|
} else {
|
||||||
|
taskRunner.failTask(taskResult);
|
||||||
|
error(`Task "${task.name}" failed`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return taskSuccess;
|
||||||
|
} catch (err) {
|
||||||
|
const completedAt = new Date().toISOString();
|
||||||
|
|
||||||
|
taskRunner.failTask({
|
||||||
|
task,
|
||||||
|
success: false,
|
||||||
|
response: getErrorMessage(err),
|
||||||
|
executionLog,
|
||||||
|
startedAt,
|
||||||
|
completedAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
error(`Task "${task.name}" error: ${getErrorMessage(err)}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run all pending tasks from .takt/tasks/
|
||||||
|
*
|
||||||
|
* タスクを動的に取得する。各タスク実行前に次のタスクを取得するため、
|
||||||
|
* 実行中にタスクファイルが追加・削除されても反映される。
|
||||||
|
*/
|
||||||
|
export async function runAllTasks(
|
||||||
|
cwd: string,
|
||||||
|
workflowName: string = DEFAULT_WORKFLOW_NAME,
|
||||||
|
options?: TaskExecutionOptions,
|
||||||
|
): Promise<void> {
|
||||||
|
const taskRunner = new TaskRunner(cwd);
|
||||||
|
|
||||||
|
// 最初のタスクを取得
|
||||||
|
let task = taskRunner.getNextTask();
|
||||||
|
|
||||||
|
if (!task) {
|
||||||
|
info('No pending tasks in .takt/tasks/');
|
||||||
|
info('Create task files as .takt/tasks/*.yaml or use takt add');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
header('Running tasks');
|
||||||
|
|
||||||
|
let successCount = 0;
|
||||||
|
let failCount = 0;
|
||||||
|
|
||||||
|
while (task) {
|
||||||
|
blankLine();
|
||||||
|
info(`=== Task: ${task.name} ===`);
|
||||||
|
blankLine();
|
||||||
|
|
||||||
|
const taskSuccess = await executeAndCompleteTask(task, taskRunner, cwd, workflowName, options);
|
||||||
|
|
||||||
|
if (taskSuccess) {
|
||||||
|
successCount++;
|
||||||
|
} else {
|
||||||
|
failCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 次のタスクを動的に取得(新しく追加されたタスクも含む)
|
||||||
|
task = taskRunner.getNextTask();
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalCount = successCount + failCount;
|
||||||
|
blankLine();
|
||||||
|
header('Tasks Summary');
|
||||||
|
status('Total', String(totalCount));
|
||||||
|
status('Success', String(successCount), successCount === totalCount ? 'green' : undefined);
|
||||||
|
if (failCount > 0) {
|
||||||
|
status('Failed', String(failCount), 'red');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve execution directory and workflow from task data.
|
||||||
|
* If the task has worktree settings, create a shared clone and use it as cwd.
|
||||||
|
* Task name is summarized to English by AI for use in branch/clone names.
|
||||||
|
*/
|
||||||
|
export async function resolveTaskExecution(
|
||||||
|
task: TaskInfo,
|
||||||
|
defaultCwd: string,
|
||||||
|
defaultWorkflow: string
|
||||||
|
): Promise<{ execCwd: string; execWorkflow: string; isWorktree: boolean; branch?: string }> {
|
||||||
|
const data = task.data;
|
||||||
|
|
||||||
|
// No structured data: use defaults
|
||||||
|
if (!data) {
|
||||||
|
return { execCwd: defaultCwd, execWorkflow: defaultWorkflow, isWorktree: false };
|
||||||
|
}
|
||||||
|
|
||||||
|
let execCwd = defaultCwd;
|
||||||
|
let isWorktree = false;
|
||||||
|
let branch: string | undefined;
|
||||||
|
|
||||||
|
// Handle worktree (now creates a shared clone)
|
||||||
|
if (data.worktree) {
|
||||||
|
// Summarize task content to English slug using AI
|
||||||
|
info('Generating branch name...');
|
||||||
|
const taskSlug = await summarizeTaskName(task.content, { cwd: defaultCwd });
|
||||||
|
|
||||||
|
const result = createSharedClone(defaultCwd, {
|
||||||
|
worktree: data.worktree,
|
||||||
|
branch: data.branch,
|
||||||
|
taskSlug,
|
||||||
|
issueNumber: data.issue,
|
||||||
|
});
|
||||||
|
execCwd = result.path;
|
||||||
|
branch = result.branch;
|
||||||
|
isWorktree = true;
|
||||||
|
info(`Clone created: ${result.path} (branch: ${result.branch})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle workflow override
|
||||||
|
const execWorkflow = data.workflow || defaultWorkflow;
|
||||||
|
|
||||||
|
return { execCwd, execWorkflow, isWorktree, branch };
|
||||||
|
}
|
||||||
359
src/commands/execution/workflowExecution.ts
Normal file
359
src/commands/execution/workflowExecution.ts
Normal file
@ -0,0 +1,359 @@
|
|||||||
|
/**
|
||||||
|
* Workflow execution logic
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { readFileSync } from 'node:fs';
|
||||||
|
import { WorkflowEngine } from '../../workflow/engine.js';
|
||||||
|
import type { WorkflowConfig, Language } from '../../models/types.js';
|
||||||
|
import type { IterationLimitRequest } from '../../workflow/types.js';
|
||||||
|
import type { ProviderType } from '../../providers/index.js';
|
||||||
|
import {
|
||||||
|
loadAgentSessions,
|
||||||
|
updateAgentSession,
|
||||||
|
loadWorktreeSessions,
|
||||||
|
updateWorktreeSession,
|
||||||
|
} from '../../config/paths.js';
|
||||||
|
import { loadGlobalConfig } from '../../config/globalConfig.js';
|
||||||
|
import { isQuietMode } from '../../context.js';
|
||||||
|
import {
|
||||||
|
header,
|
||||||
|
info,
|
||||||
|
warn,
|
||||||
|
error,
|
||||||
|
success,
|
||||||
|
status,
|
||||||
|
blankLine,
|
||||||
|
StreamDisplay,
|
||||||
|
} from '../../utils/ui.js';
|
||||||
|
import {
|
||||||
|
generateSessionId,
|
||||||
|
createSessionLog,
|
||||||
|
finalizeSessionLog,
|
||||||
|
updateLatestPointer,
|
||||||
|
initNdjsonLog,
|
||||||
|
appendNdjsonLine,
|
||||||
|
type NdjsonStepStart,
|
||||||
|
type NdjsonStepComplete,
|
||||||
|
type NdjsonWorkflowComplete,
|
||||||
|
type NdjsonWorkflowAbort,
|
||||||
|
} from '../../utils/session.js';
|
||||||
|
import { createLogger } from '../../utils/debug.js';
|
||||||
|
import { notifySuccess, notifyError } from '../../utils/notification.js';
|
||||||
|
import { selectOption, promptInput } from '../../prompt/index.js';
|
||||||
|
import { EXIT_SIGINT } from '../../exitCodes.js';
|
||||||
|
|
||||||
|
const log = createLogger('workflow');
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format elapsed time in human-readable format
|
||||||
|
*/
|
||||||
|
function formatElapsedTime(startTime: string, endTime: string): string {
|
||||||
|
const start = new Date(startTime).getTime();
|
||||||
|
const end = new Date(endTime).getTime();
|
||||||
|
const elapsedMs = end - start;
|
||||||
|
const elapsedSec = elapsedMs / 1000;
|
||||||
|
|
||||||
|
if (elapsedSec < 60) {
|
||||||
|
return `${elapsedSec.toFixed(1)}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const minutes = Math.floor(elapsedSec / 60);
|
||||||
|
const seconds = Math.floor(elapsedSec % 60);
|
||||||
|
return `${minutes}m ${seconds}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Result of workflow execution */
|
||||||
|
export interface WorkflowExecutionResult {
|
||||||
|
success: boolean;
|
||||||
|
reason?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Options for workflow execution */
|
||||||
|
export interface WorkflowExecutionOptions {
|
||||||
|
/** Header prefix for display */
|
||||||
|
headerPrefix?: string;
|
||||||
|
/** Project root directory (where .takt/ lives). */
|
||||||
|
projectCwd: string;
|
||||||
|
/** Language for instruction metadata */
|
||||||
|
language?: Language;
|
||||||
|
provider?: ProviderType;
|
||||||
|
model?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a workflow and handle all events
|
||||||
|
*/
|
||||||
|
export async function executeWorkflow(
|
||||||
|
workflowConfig: WorkflowConfig,
|
||||||
|
task: string,
|
||||||
|
cwd: string,
|
||||||
|
options: WorkflowExecutionOptions
|
||||||
|
): Promise<WorkflowExecutionResult> {
|
||||||
|
const {
|
||||||
|
headerPrefix = 'Running Workflow:',
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
// projectCwd is where .takt/ lives (project root, not the clone)
|
||||||
|
const projectCwd = options.projectCwd;
|
||||||
|
|
||||||
|
// Always continue from previous sessions (use /clear to reset)
|
||||||
|
log.debug('Continuing session (use /clear to reset)');
|
||||||
|
|
||||||
|
header(`${headerPrefix} ${workflowConfig.name}`);
|
||||||
|
|
||||||
|
const workflowSessionId = generateSessionId();
|
||||||
|
let sessionLog = createSessionLog(task, projectCwd, workflowConfig.name);
|
||||||
|
|
||||||
|
// Initialize NDJSON log file + pointer at workflow start
|
||||||
|
const ndjsonLogPath = initNdjsonLog(workflowSessionId, task, workflowConfig.name, projectCwd);
|
||||||
|
updateLatestPointer(sessionLog, workflowSessionId, projectCwd, { copyToPrevious: true });
|
||||||
|
|
||||||
|
// Track current display for streaming
|
||||||
|
const displayRef: { current: StreamDisplay | null } = { current: null };
|
||||||
|
|
||||||
|
// Create stream handler that delegates to UI display
|
||||||
|
const streamHandler = (
|
||||||
|
event: Parameters<ReturnType<StreamDisplay['createHandler']>>[0]
|
||||||
|
): void => {
|
||||||
|
if (!displayRef.current) return;
|
||||||
|
if (event.type === 'result') return;
|
||||||
|
displayRef.current.createHandler()(event);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Load saved agent sessions for continuity (from project root or clone-specific storage)
|
||||||
|
const isWorktree = cwd !== projectCwd;
|
||||||
|
const currentProvider = loadGlobalConfig().provider ?? 'claude';
|
||||||
|
const savedSessions = isWorktree
|
||||||
|
? loadWorktreeSessions(projectCwd, cwd, currentProvider)
|
||||||
|
: loadAgentSessions(projectCwd, currentProvider);
|
||||||
|
|
||||||
|
// Session update handler - persist session IDs when they change
|
||||||
|
// Clone sessions are stored separately per clone path
|
||||||
|
const sessionUpdateHandler = isWorktree
|
||||||
|
? (agentName: string, agentSessionId: string): void => {
|
||||||
|
updateWorktreeSession(projectCwd, cwd, agentName, agentSessionId, currentProvider);
|
||||||
|
}
|
||||||
|
: (agentName: string, agentSessionId: string): void => {
|
||||||
|
updateAgentSession(projectCwd, agentName, agentSessionId, currentProvider);
|
||||||
|
};
|
||||||
|
|
||||||
|
const iterationLimitHandler = async (
|
||||||
|
request: IterationLimitRequest
|
||||||
|
): Promise<number | null> => {
|
||||||
|
if (displayRef.current) {
|
||||||
|
displayRef.current.flush();
|
||||||
|
displayRef.current = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
blankLine();
|
||||||
|
warn(
|
||||||
|
`最大イテレーションに到達しました (${request.currentIteration}/${request.maxIterations})`
|
||||||
|
);
|
||||||
|
info(`現在のステップ: ${request.currentStep}`);
|
||||||
|
|
||||||
|
const action = await selectOption('続行しますか?', [
|
||||||
|
{
|
||||||
|
label: '続行する(追加イテレーション数を入力)',
|
||||||
|
value: 'continue',
|
||||||
|
description: '入力した回数だけ上限を増やします',
|
||||||
|
},
|
||||||
|
{ label: '終了する', value: 'stop' },
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (action !== 'continue') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const input = await promptInput('追加するイテレーション数を入力してください(1以上)');
|
||||||
|
if (!input) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const additionalIterations = Number.parseInt(input, 10);
|
||||||
|
if (Number.isInteger(additionalIterations) && additionalIterations > 0) {
|
||||||
|
workflowConfig.maxIterations += additionalIterations;
|
||||||
|
return additionalIterations;
|
||||||
|
}
|
||||||
|
|
||||||
|
warn('1以上の整数を入力してください。');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const engine = new WorkflowEngine(workflowConfig, cwd, task, {
|
||||||
|
onStream: streamHandler,
|
||||||
|
initialSessions: savedSessions,
|
||||||
|
onSessionUpdate: sessionUpdateHandler,
|
||||||
|
onIterationLimit: iterationLimitHandler,
|
||||||
|
projectCwd,
|
||||||
|
language: options.language,
|
||||||
|
provider: options.provider,
|
||||||
|
model: options.model,
|
||||||
|
});
|
||||||
|
|
||||||
|
let abortReason: string | undefined;
|
||||||
|
|
||||||
|
engine.on('step:start', (step, iteration, instruction) => {
|
||||||
|
log.debug('Step starting', { step: step.name, agent: step.agentDisplayName, iteration });
|
||||||
|
info(`[${iteration}/${workflowConfig.maxIterations}] ${step.name} (${step.agentDisplayName})`);
|
||||||
|
|
||||||
|
// Log prompt content for debugging
|
||||||
|
if (instruction) {
|
||||||
|
log.debug('Step instruction', instruction);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use quiet mode from CLI (already resolved CLI flag + config in preAction)
|
||||||
|
displayRef.current = new StreamDisplay(step.agentDisplayName, isQuietMode());
|
||||||
|
|
||||||
|
// Write step_start record to NDJSON log
|
||||||
|
const record: NdjsonStepStart = {
|
||||||
|
type: 'step_start',
|
||||||
|
step: step.name,
|
||||||
|
agent: step.agentDisplayName,
|
||||||
|
iteration,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
...(instruction ? { instruction } : {}),
|
||||||
|
};
|
||||||
|
appendNdjsonLine(ndjsonLogPath, record);
|
||||||
|
});
|
||||||
|
|
||||||
|
engine.on('step:complete', (step, response, instruction) => {
|
||||||
|
log.debug('Step completed', {
|
||||||
|
step: step.name,
|
||||||
|
status: response.status,
|
||||||
|
matchedRuleIndex: response.matchedRuleIndex,
|
||||||
|
matchedRuleMethod: response.matchedRuleMethod,
|
||||||
|
contentLength: response.content.length,
|
||||||
|
sessionId: response.sessionId,
|
||||||
|
error: response.error,
|
||||||
|
});
|
||||||
|
if (displayRef.current) {
|
||||||
|
displayRef.current.flush();
|
||||||
|
displayRef.current = null;
|
||||||
|
}
|
||||||
|
blankLine();
|
||||||
|
|
||||||
|
if (response.matchedRuleIndex != null && step.rules) {
|
||||||
|
const rule = step.rules[response.matchedRuleIndex];
|
||||||
|
if (rule) {
|
||||||
|
const methodLabel = response.matchedRuleMethod ? ` (${response.matchedRuleMethod})` : '';
|
||||||
|
status('Status', `${rule.condition}${methodLabel}`);
|
||||||
|
} else {
|
||||||
|
status('Status', response.status);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
status('Status', response.status);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (response.error) {
|
||||||
|
error(`Error: ${response.error}`);
|
||||||
|
}
|
||||||
|
if (response.sessionId) {
|
||||||
|
status('Session', response.sessionId);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write step_complete record to NDJSON log
|
||||||
|
const record: NdjsonStepComplete = {
|
||||||
|
type: 'step_complete',
|
||||||
|
step: step.name,
|
||||||
|
agent: response.agent,
|
||||||
|
status: response.status,
|
||||||
|
content: response.content,
|
||||||
|
instruction,
|
||||||
|
...(response.matchedRuleIndex != null ? { matchedRuleIndex: response.matchedRuleIndex } : {}),
|
||||||
|
...(response.matchedRuleMethod ? { matchedRuleMethod: response.matchedRuleMethod } : {}),
|
||||||
|
...(response.error ? { error: response.error } : {}),
|
||||||
|
timestamp: response.timestamp.toISOString(),
|
||||||
|
};
|
||||||
|
appendNdjsonLine(ndjsonLogPath, record);
|
||||||
|
|
||||||
|
// Update in-memory log for pointer metadata (immutable)
|
||||||
|
sessionLog = { ...sessionLog, iterations: sessionLog.iterations + 1 };
|
||||||
|
updateLatestPointer(sessionLog, workflowSessionId, projectCwd);
|
||||||
|
});
|
||||||
|
|
||||||
|
engine.on('step:report', (_step, filePath, fileName) => {
|
||||||
|
const content = readFileSync(filePath, 'utf-8');
|
||||||
|
console.log(`\n📄 Report: ${fileName}\n`);
|
||||||
|
console.log(content);
|
||||||
|
});
|
||||||
|
|
||||||
|
engine.on('workflow:complete', (state) => {
|
||||||
|
log.info('Workflow completed successfully', { iterations: state.iteration });
|
||||||
|
sessionLog = finalizeSessionLog(sessionLog, 'completed');
|
||||||
|
|
||||||
|
// Write workflow_complete record to NDJSON log
|
||||||
|
const record: NdjsonWorkflowComplete = {
|
||||||
|
type: 'workflow_complete',
|
||||||
|
iterations: state.iteration,
|
||||||
|
endTime: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
appendNdjsonLine(ndjsonLogPath, record);
|
||||||
|
updateLatestPointer(sessionLog, workflowSessionId, projectCwd);
|
||||||
|
|
||||||
|
const elapsed = sessionLog.endTime
|
||||||
|
? formatElapsedTime(sessionLog.startTime, sessionLog.endTime)
|
||||||
|
: '';
|
||||||
|
const elapsedDisplay = elapsed ? `, ${elapsed}` : '';
|
||||||
|
|
||||||
|
success(`Workflow completed (${state.iteration} iterations${elapsedDisplay})`);
|
||||||
|
info(`Session log: ${ndjsonLogPath}`);
|
||||||
|
notifySuccess('TAKT', `ワークフロー完了 (${state.iteration} iterations)`);
|
||||||
|
});
|
||||||
|
|
||||||
|
engine.on('workflow:abort', (state, reason) => {
|
||||||
|
log.error('Workflow aborted', { reason, iterations: state.iteration });
|
||||||
|
if (displayRef.current) {
|
||||||
|
displayRef.current.flush();
|
||||||
|
displayRef.current = null;
|
||||||
|
}
|
||||||
|
abortReason = reason;
|
||||||
|
sessionLog = finalizeSessionLog(sessionLog, 'aborted');
|
||||||
|
|
||||||
|
// Write workflow_abort record to NDJSON log
|
||||||
|
const record: NdjsonWorkflowAbort = {
|
||||||
|
type: 'workflow_abort',
|
||||||
|
iterations: state.iteration,
|
||||||
|
reason,
|
||||||
|
endTime: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
appendNdjsonLine(ndjsonLogPath, record);
|
||||||
|
updateLatestPointer(sessionLog, workflowSessionId, projectCwd);
|
||||||
|
|
||||||
|
const elapsed = sessionLog.endTime
|
||||||
|
? formatElapsedTime(sessionLog.startTime, sessionLog.endTime)
|
||||||
|
: '';
|
||||||
|
const elapsedDisplay = elapsed ? ` (${elapsed})` : '';
|
||||||
|
|
||||||
|
error(`Workflow aborted after ${state.iteration} iterations${elapsedDisplay}: ${reason}`);
|
||||||
|
info(`Session log: ${ndjsonLogPath}`);
|
||||||
|
notifyError('TAKT', `中断: ${reason}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
// SIGINT handler: 1st Ctrl+C = graceful abort, 2nd = force exit
|
||||||
|
let sigintCount = 0;
|
||||||
|
const onSigInt = () => {
|
||||||
|
sigintCount++;
|
||||||
|
if (sigintCount === 1) {
|
||||||
|
blankLine();
|
||||||
|
warn('Ctrl+C: ワークフローを中断しています...');
|
||||||
|
engine.abort();
|
||||||
|
} else {
|
||||||
|
blankLine();
|
||||||
|
error('Ctrl+C: 強制終了します');
|
||||||
|
process.exit(EXIT_SIGINT);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
process.on('SIGINT', onSigInt);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const finalState = await engine.run();
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: finalState.status === 'completed',
|
||||||
|
reason: abortReason,
|
||||||
|
};
|
||||||
|
} finally {
|
||||||
|
process.removeListener('SIGINT', onSigInt);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,247 +1,2 @@
|
|||||||
/**
|
/** Re-export shim — actual implementation in interactive/interactive.ts */
|
||||||
* Interactive task input mode
|
export { interactiveMode } from './interactive/interactive.js';
|
||||||
*
|
|
||||||
* 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 { isQuietMode } from '../context.js';
|
|
||||||
import { loadAgentSessions, updateAgentSession } from '../config/paths.js';
|
|
||||||
import { getProvider, type ProviderType } from '../providers/index.js';
|
|
||||||
import { createLogger } from '../utils/debug.js';
|
|
||||||
import { getErrorMessage } from '../utils/error.js';
|
|
||||||
import { info, error, blankLine, 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;
|
|
||||||
success: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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();
|
|
||||||
const success = response.status !== 'blocked';
|
|
||||||
return { content: response.content, sessionId: response.sessionId, success };
|
|
||||||
}
|
|
||||||
|
|
||||||
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, providerType);
|
|
||||||
let sessionId: string | undefined = savedSessions[agentName];
|
|
||||||
|
|
||||||
info('Interactive mode - describe your task. Commands: /go (execute), /cancel (exit)');
|
|
||||||
if (sessionId) {
|
|
||||||
info('Resuming previous session');
|
|
||||||
}
|
|
||||||
blankLine();
|
|
||||||
|
|
||||||
/** Call AI with automatic retry on session error (stale/invalid session ID). */
|
|
||||||
async function callAIWithRetry(prompt: string): Promise<CallAIResult | null> {
|
|
||||||
const display = new StreamDisplay('assistant', isQuietMode());
|
|
||||||
try {
|
|
||||||
const result = await callAI(provider, prompt, cwd, model, sessionId, display);
|
|
||||||
// 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);
|
|
||||||
if (retry.sessionId) {
|
|
||||||
sessionId = retry.sessionId;
|
|
||||||
updateAgentSession(cwd, agentName, sessionId, providerType);
|
|
||||||
}
|
|
||||||
return retry;
|
|
||||||
}
|
|
||||||
if (result.sessionId) {
|
|
||||||
sessionId = result.sessionId;
|
|
||||||
updateAgentSession(cwd, agentName, sessionId, providerType);
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
} catch (e) {
|
|
||||||
const msg = getErrorMessage(e);
|
|
||||||
log.error('AI call failed', { error: msg });
|
|
||||||
error(msg);
|
|
||||||
blankLine();
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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 result = await callAIWithRetry(initialInput);
|
|
||||||
if (result) {
|
|
||||||
history.push({ role: 'assistant', content: result.content });
|
|
||||||
blankLine();
|
|
||||||
} else {
|
|
||||||
history.pop();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
const input = await readLine(chalk.green('> '));
|
|
||||||
|
|
||||||
// EOF (Ctrl+D)
|
|
||||||
if (input === null) {
|
|
||||||
blankLine();
|
|
||||||
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 result = await callAIWithRetry(trimmed);
|
|
||||||
if (result) {
|
|
||||||
history.push({ role: 'assistant', content: result.content });
|
|
||||||
blankLine();
|
|
||||||
} else {
|
|
||||||
history.pop();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
5
src/commands/interactive/index.ts
Normal file
5
src/commands/interactive/index.ts
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
/**
|
||||||
|
* Interactive mode commands.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { interactiveMode } from './interactive.js';
|
||||||
247
src/commands/interactive/interactive.ts
Normal file
247
src/commands/interactive/interactive.ts
Normal file
@ -0,0 +1,247 @@
|
|||||||
|
/**
|
||||||
|
* 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 { isQuietMode } from '../../context.js';
|
||||||
|
import { loadAgentSessions, updateAgentSession } from '../../config/paths.js';
|
||||||
|
import { getProvider, type ProviderType } from '../../providers/index.js';
|
||||||
|
import { createLogger } from '../../utils/debug.js';
|
||||||
|
import { getErrorMessage } from '../../utils/error.js';
|
||||||
|
import { info, error, blankLine, 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;
|
||||||
|
success: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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();
|
||||||
|
const success = response.status !== 'blocked';
|
||||||
|
return { content: response.content, sessionId: response.sessionId, success };
|
||||||
|
}
|
||||||
|
|
||||||
|
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, providerType);
|
||||||
|
let sessionId: string | undefined = savedSessions[agentName];
|
||||||
|
|
||||||
|
info('Interactive mode - describe your task. Commands: /go (execute), /cancel (exit)');
|
||||||
|
if (sessionId) {
|
||||||
|
info('Resuming previous session');
|
||||||
|
}
|
||||||
|
blankLine();
|
||||||
|
|
||||||
|
/** Call AI with automatic retry on session error (stale/invalid session ID). */
|
||||||
|
async function callAIWithRetry(prompt: string): Promise<CallAIResult | null> {
|
||||||
|
const display = new StreamDisplay('assistant', isQuietMode());
|
||||||
|
try {
|
||||||
|
const result = await callAI(provider, prompt, cwd, model, sessionId, display);
|
||||||
|
// 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);
|
||||||
|
if (retry.sessionId) {
|
||||||
|
sessionId = retry.sessionId;
|
||||||
|
updateAgentSession(cwd, agentName, sessionId, providerType);
|
||||||
|
}
|
||||||
|
return retry;
|
||||||
|
}
|
||||||
|
if (result.sessionId) {
|
||||||
|
sessionId = result.sessionId;
|
||||||
|
updateAgentSession(cwd, agentName, sessionId, providerType);
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
} catch (e) {
|
||||||
|
const msg = getErrorMessage(e);
|
||||||
|
log.error('AI call failed', { error: msg });
|
||||||
|
error(msg);
|
||||||
|
blankLine();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 result = await callAIWithRetry(initialInput);
|
||||||
|
if (result) {
|
||||||
|
history.push({ role: 'assistant', content: result.content });
|
||||||
|
blankLine();
|
||||||
|
} else {
|
||||||
|
history.pop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const input = await readLine(chalk.green('> '));
|
||||||
|
|
||||||
|
// EOF (Ctrl+D)
|
||||||
|
if (input === null) {
|
||||||
|
blankLine();
|
||||||
|
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 result = await callAIWithRetry(trimmed);
|
||||||
|
if (result) {
|
||||||
|
history.push({ role: 'assistant', content: result.content });
|
||||||
|
blankLine();
|
||||||
|
} else {
|
||||||
|
history.pop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,441 +1,2 @@
|
|||||||
/**
|
/** Re-export shim — actual implementation in management/listTasks.ts */
|
||||||
* List tasks command
|
export { listTasks, isBranchMerged, showFullDiff, type ListAction } from './management/listTasks.js';
|
||||||
*
|
|
||||||
* Interactive UI for reviewing branch-based task results:
|
|
||||||
* try merge, merge & cleanup, or delete actions.
|
|
||||||
* Clones are ephemeral — only branches persist between sessions.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { execFileSync, spawnSync } from 'node:child_process';
|
|
||||||
import chalk from 'chalk';
|
|
||||||
import {
|
|
||||||
createTempCloneForBranch,
|
|
||||||
removeClone,
|
|
||||||
removeCloneMeta,
|
|
||||||
cleanupOrphanedClone,
|
|
||||||
} from '../task/clone.js';
|
|
||||||
import {
|
|
||||||
detectDefaultBranch,
|
|
||||||
listTaktBranches,
|
|
||||||
buildListItems,
|
|
||||||
type BranchListItem,
|
|
||||||
} from '../task/branchList.js';
|
|
||||||
import { autoCommitAndPush } from '../task/autoCommit.js';
|
|
||||||
import { selectOption, confirm, promptInput } from '../prompt/index.js';
|
|
||||||
import { info, success, error as logError, warn, header, blankLine } from '../utils/ui.js';
|
|
||||||
import { createLogger } from '../utils/debug.js';
|
|
||||||
import { getErrorMessage } from '../utils/error.js';
|
|
||||||
import { executeTask, type TaskExecutionOptions } from './taskExecution.js';
|
|
||||||
import { listWorkflows } from '../config/workflowLoader.js';
|
|
||||||
import { getCurrentWorkflow } from '../config/paths.js';
|
|
||||||
import { DEFAULT_WORKFLOW_NAME } from '../constants.js';
|
|
||||||
|
|
||||||
const log = createLogger('list-tasks');
|
|
||||||
|
|
||||||
/** Actions available for a listed branch */
|
|
||||||
export type ListAction = 'diff' | 'instruct' | 'try' | 'merge' | 'delete';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a branch has already been merged into HEAD.
|
|
||||||
*/
|
|
||||||
export function isBranchMerged(projectDir: string, branch: string): boolean {
|
|
||||||
try {
|
|
||||||
execFileSync('git', ['merge-base', '--is-ancestor', branch, 'HEAD'], {
|
|
||||||
cwd: projectDir,
|
|
||||||
encoding: 'utf-8',
|
|
||||||
stdio: 'pipe',
|
|
||||||
});
|
|
||||||
return true;
|
|
||||||
} catch {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Show full diff in an interactive pager (less).
|
|
||||||
* Falls back to direct output if pager is unavailable.
|
|
||||||
*/
|
|
||||||
export function showFullDiff(
|
|
||||||
cwd: string,
|
|
||||||
defaultBranch: string,
|
|
||||||
branch: string,
|
|
||||||
): void {
|
|
||||||
try {
|
|
||||||
const result = spawnSync(
|
|
||||||
'git', ['diff', '--color=always', `${defaultBranch}...${branch}`],
|
|
||||||
{ cwd, stdio: ['inherit', 'inherit', 'inherit'], env: { ...process.env, GIT_PAGER: 'less -R' } },
|
|
||||||
);
|
|
||||||
if (result.status !== 0) {
|
|
||||||
warn('Could not display diff');
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
warn('Could not display diff');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Show diff stat for a branch and prompt for an action.
|
|
||||||
*/
|
|
||||||
async function showDiffAndPromptAction(
|
|
||||||
cwd: string,
|
|
||||||
defaultBranch: string,
|
|
||||||
item: BranchListItem,
|
|
||||||
): Promise<ListAction | null> {
|
|
||||||
header(item.info.branch);
|
|
||||||
if (item.originalInstruction) {
|
|
||||||
console.log(chalk.dim(` ${item.originalInstruction}`));
|
|
||||||
}
|
|
||||||
blankLine();
|
|
||||||
|
|
||||||
// Show diff stat
|
|
||||||
try {
|
|
||||||
const stat = execFileSync(
|
|
||||||
'git', ['diff', '--stat', `${defaultBranch}...${item.info.branch}`],
|
|
||||||
{ cwd, encoding: 'utf-8', stdio: 'pipe' },
|
|
||||||
);
|
|
||||||
console.log(stat);
|
|
||||||
} catch {
|
|
||||||
warn('Could not generate diff stat');
|
|
||||||
}
|
|
||||||
|
|
||||||
// Prompt action
|
|
||||||
const action = await selectOption<ListAction>(
|
|
||||||
`Action for ${item.info.branch}:`,
|
|
||||||
[
|
|
||||||
{ label: 'View diff', value: 'diff', description: 'Show full diff in pager' },
|
|
||||||
{ label: 'Instruct', value: 'instruct', description: 'Give additional instructions via temp clone' },
|
|
||||||
{ label: 'Try merge', value: 'try', description: 'Squash merge (stage changes without commit)' },
|
|
||||||
{ label: 'Merge & cleanup', value: 'merge', description: 'Merge and delete branch' },
|
|
||||||
{ label: 'Delete', value: 'delete', description: 'Discard changes, delete branch' },
|
|
||||||
],
|
|
||||||
);
|
|
||||||
|
|
||||||
return action;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Try-merge (squash): stage changes from branch without committing.
|
|
||||||
* User can inspect staged changes and commit manually if satisfied.
|
|
||||||
*/
|
|
||||||
export function tryMergeBranch(projectDir: string, item: BranchListItem): boolean {
|
|
||||||
const { branch } = item.info;
|
|
||||||
|
|
||||||
try {
|
|
||||||
execFileSync('git', ['merge', '--squash', branch], {
|
|
||||||
cwd: projectDir,
|
|
||||||
encoding: 'utf-8',
|
|
||||||
stdio: 'pipe',
|
|
||||||
});
|
|
||||||
|
|
||||||
success(`Squash-merged ${branch} (changes staged, not committed)`);
|
|
||||||
info('Run `git status` to see staged changes, `git commit` to finalize, or `git reset` to undo.');
|
|
||||||
log.info('Try-merge (squash) completed', { branch });
|
|
||||||
return true;
|
|
||||||
} catch (err) {
|
|
||||||
const msg = getErrorMessage(err);
|
|
||||||
logError(`Squash merge failed: ${msg}`);
|
|
||||||
logError('You may need to resolve conflicts manually.');
|
|
||||||
log.error('Try-merge (squash) failed', { branch, error: msg });
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Merge & cleanup: if already merged, skip merge and just delete the branch.
|
|
||||||
* Otherwise merge first, then delete the branch.
|
|
||||||
* No worktree removal needed — clones are ephemeral.
|
|
||||||
*/
|
|
||||||
export function mergeBranch(projectDir: string, item: BranchListItem): boolean {
|
|
||||||
const { branch } = item.info;
|
|
||||||
const alreadyMerged = isBranchMerged(projectDir, branch);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Merge only if not already merged
|
|
||||||
if (alreadyMerged) {
|
|
||||||
info(`${branch} is already merged, skipping merge.`);
|
|
||||||
log.info('Branch already merged, cleanup only', { branch });
|
|
||||||
} else {
|
|
||||||
execFileSync('git', ['merge', branch], {
|
|
||||||
cwd: projectDir,
|
|
||||||
encoding: 'utf-8',
|
|
||||||
stdio: 'pipe',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Delete the branch
|
|
||||||
try {
|
|
||||||
execFileSync('git', ['branch', '-d', branch], {
|
|
||||||
cwd: projectDir,
|
|
||||||
encoding: 'utf-8',
|
|
||||||
stdio: 'pipe',
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
warn(`Could not delete branch ${branch}. You may delete it manually.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Clean up orphaned clone directory if it still exists
|
|
||||||
cleanupOrphanedClone(projectDir, branch);
|
|
||||||
|
|
||||||
success(`Merged & cleaned up ${branch}`);
|
|
||||||
log.info('Branch merged & cleaned up', { branch, alreadyMerged });
|
|
||||||
return true;
|
|
||||||
} catch (err) {
|
|
||||||
const msg = getErrorMessage(err);
|
|
||||||
logError(`Merge failed: ${msg}`);
|
|
||||||
logError('You may need to resolve conflicts manually.');
|
|
||||||
log.error('Merge & cleanup failed', { branch, error: msg });
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Delete a branch (discard changes).
|
|
||||||
* No worktree removal needed — clones are ephemeral.
|
|
||||||
*/
|
|
||||||
export function deleteBranch(projectDir: string, item: BranchListItem): boolean {
|
|
||||||
const { branch } = item.info;
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Force-delete the branch
|
|
||||||
execFileSync('git', ['branch', '-D', branch], {
|
|
||||||
cwd: projectDir,
|
|
||||||
encoding: 'utf-8',
|
|
||||||
stdio: 'pipe',
|
|
||||||
});
|
|
||||||
|
|
||||||
// Clean up orphaned clone directory if it still exists
|
|
||||||
cleanupOrphanedClone(projectDir, branch);
|
|
||||||
|
|
||||||
success(`Deleted ${branch}`);
|
|
||||||
log.info('Branch deleted', { branch });
|
|
||||||
return true;
|
|
||||||
} catch (err) {
|
|
||||||
const msg = getErrorMessage(err);
|
|
||||||
logError(`Delete failed: ${msg}`);
|
|
||||||
log.error('Delete failed', { branch, error: msg });
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the workflow to use for instruction.
|
|
||||||
* If multiple workflows available, prompt user to select.
|
|
||||||
*/
|
|
||||||
async function selectWorkflowForInstruction(projectDir: string): Promise<string | null> {
|
|
||||||
const availableWorkflows = listWorkflows(projectDir);
|
|
||||||
const currentWorkflow = getCurrentWorkflow(projectDir);
|
|
||||||
|
|
||||||
if (availableWorkflows.length === 0) {
|
|
||||||
return DEFAULT_WORKFLOW_NAME;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (availableWorkflows.length === 1 && availableWorkflows[0]) {
|
|
||||||
return availableWorkflows[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Multiple workflows: let user select
|
|
||||||
const options = availableWorkflows.map((name) => ({
|
|
||||||
label: name === currentWorkflow ? `${name} (current)` : name,
|
|
||||||
value: name,
|
|
||||||
}));
|
|
||||||
|
|
||||||
return await selectOption('Select workflow:', options);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get branch context: diff stat and commit log from main branch.
|
|
||||||
*/
|
|
||||||
function getBranchContext(projectDir: string, branch: string): string {
|
|
||||||
const defaultBranch = detectDefaultBranch(projectDir);
|
|
||||||
const lines: string[] = [];
|
|
||||||
|
|
||||||
// Get diff stat
|
|
||||||
try {
|
|
||||||
const diffStat = execFileSync(
|
|
||||||
'git', ['diff', '--stat', `${defaultBranch}...${branch}`],
|
|
||||||
{ cwd: projectDir, encoding: 'utf-8', stdio: 'pipe' }
|
|
||||||
).trim();
|
|
||||||
if (diffStat) {
|
|
||||||
lines.push('## 現在の変更内容(mainからの差分)');
|
|
||||||
lines.push('```');
|
|
||||||
lines.push(diffStat);
|
|
||||||
lines.push('```');
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Ignore errors
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get commit log
|
|
||||||
try {
|
|
||||||
const commitLog = execFileSync(
|
|
||||||
'git', ['log', '--oneline', `${defaultBranch}..${branch}`],
|
|
||||||
{ cwd: projectDir, encoding: 'utf-8', stdio: 'pipe' }
|
|
||||||
).trim();
|
|
||||||
if (commitLog) {
|
|
||||||
lines.push('');
|
|
||||||
lines.push('## コミット履歴');
|
|
||||||
lines.push('```');
|
|
||||||
lines.push(commitLog);
|
|
||||||
lines.push('```');
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Ignore errors
|
|
||||||
}
|
|
||||||
|
|
||||||
return lines.length > 0 ? lines.join('\n') + '\n\n' : '';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Instruct branch: create a temp clone, give additional instructions,
|
|
||||||
* auto-commit+push, then remove clone.
|
|
||||||
*/
|
|
||||||
export async function instructBranch(
|
|
||||||
projectDir: string,
|
|
||||||
item: BranchListItem,
|
|
||||||
options?: TaskExecutionOptions,
|
|
||||||
): Promise<boolean> {
|
|
||||||
const { branch } = item.info;
|
|
||||||
|
|
||||||
// 1. Prompt for instruction
|
|
||||||
const instruction = await promptInput('Enter instruction');
|
|
||||||
if (!instruction) {
|
|
||||||
info('Cancelled');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Select workflow
|
|
||||||
const selectedWorkflow = await selectWorkflowForInstruction(projectDir);
|
|
||||||
if (!selectedWorkflow) {
|
|
||||||
info('Cancelled');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
log.info('Instructing branch via temp clone', { branch, workflow: selectedWorkflow });
|
|
||||||
info(`Running instruction on ${branch}...`);
|
|
||||||
|
|
||||||
// 3. Create temp clone for the branch
|
|
||||||
const clone = createTempCloneForBranch(projectDir, branch);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// 4. Build instruction with branch context
|
|
||||||
const branchContext = getBranchContext(projectDir, branch);
|
|
||||||
const fullInstruction = branchContext
|
|
||||||
? `${branchContext}## 追加指示\n${instruction}`
|
|
||||||
: instruction;
|
|
||||||
|
|
||||||
// 5. Execute task on temp clone
|
|
||||||
const taskSuccess = await executeTask({
|
|
||||||
task: fullInstruction,
|
|
||||||
cwd: clone.path,
|
|
||||||
workflowIdentifier: selectedWorkflow,
|
|
||||||
projectCwd: projectDir,
|
|
||||||
agentOverrides: options,
|
|
||||||
});
|
|
||||||
|
|
||||||
// 6. Auto-commit+push if successful
|
|
||||||
if (taskSuccess) {
|
|
||||||
const commitResult = autoCommitAndPush(clone.path, item.taskSlug, projectDir);
|
|
||||||
if (commitResult.success && commitResult.commitHash) {
|
|
||||||
info(`Auto-committed & pushed: ${commitResult.commitHash}`);
|
|
||||||
} else if (!commitResult.success) {
|
|
||||||
warn(`Auto-commit skipped: ${commitResult.message}`);
|
|
||||||
}
|
|
||||||
success(`Instruction completed on ${branch}`);
|
|
||||||
log.info('Instruction completed', { branch });
|
|
||||||
} else {
|
|
||||||
logError(`Instruction failed on ${branch}`);
|
|
||||||
log.error('Instruction failed', { branch });
|
|
||||||
}
|
|
||||||
|
|
||||||
return taskSuccess;
|
|
||||||
} finally {
|
|
||||||
// 7. Always remove temp clone and metadata
|
|
||||||
removeClone(clone.path);
|
|
||||||
removeCloneMeta(projectDir, branch);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Main entry point: list branch-based tasks interactively.
|
|
||||||
*/
|
|
||||||
export async function listTasks(cwd: string, options?: TaskExecutionOptions): Promise<void> {
|
|
||||||
log.info('Starting list-tasks');
|
|
||||||
|
|
||||||
const defaultBranch = detectDefaultBranch(cwd);
|
|
||||||
let branches = listTaktBranches(cwd);
|
|
||||||
|
|
||||||
if (branches.length === 0) {
|
|
||||||
info('No tasks to list.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Interactive loop
|
|
||||||
while (branches.length > 0) {
|
|
||||||
const items = buildListItems(cwd, branches, defaultBranch);
|
|
||||||
|
|
||||||
// Build selection options
|
|
||||||
const menuOptions = items.map((item, idx) => {
|
|
||||||
const filesSummary = `${item.filesChanged} file${item.filesChanged !== 1 ? 's' : ''} changed`;
|
|
||||||
const description = item.originalInstruction
|
|
||||||
? `${filesSummary} | ${item.originalInstruction}`
|
|
||||||
: filesSummary;
|
|
||||||
return {
|
|
||||||
label: item.info.branch,
|
|
||||||
value: String(idx),
|
|
||||||
description,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
const selected = await selectOption<string>(
|
|
||||||
'List Tasks (Branches)',
|
|
||||||
menuOptions,
|
|
||||||
);
|
|
||||||
|
|
||||||
if (selected === null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const selectedIdx = parseInt(selected, 10);
|
|
||||||
const item = items[selectedIdx];
|
|
||||||
if (!item) continue;
|
|
||||||
|
|
||||||
// Action loop: re-show menu after viewing diff
|
|
||||||
let action: ListAction | null;
|
|
||||||
do {
|
|
||||||
action = await showDiffAndPromptAction(cwd, defaultBranch, item);
|
|
||||||
|
|
||||||
if (action === 'diff') {
|
|
||||||
showFullDiff(cwd, defaultBranch, item.info.branch);
|
|
||||||
}
|
|
||||||
} while (action === 'diff');
|
|
||||||
|
|
||||||
if (action === null) continue;
|
|
||||||
|
|
||||||
switch (action) {
|
|
||||||
case 'instruct':
|
|
||||||
await instructBranch(cwd, item, options);
|
|
||||||
break;
|
|
||||||
case 'try':
|
|
||||||
tryMergeBranch(cwd, item);
|
|
||||||
break;
|
|
||||||
case 'merge':
|
|
||||||
mergeBranch(cwd, item);
|
|
||||||
break;
|
|
||||||
case 'delete': {
|
|
||||||
const confirmed = await confirm(
|
|
||||||
`Delete ${item.info.branch}? This will discard all changes.`,
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
if (confirmed) {
|
|
||||||
deleteBranch(cwd, item);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Refresh branch list after action
|
|
||||||
branches = listTaktBranches(cwd);
|
|
||||||
}
|
|
||||||
|
|
||||||
info('All tasks listed.');
|
|
||||||
}
|
|
||||||
|
|||||||
185
src/commands/management/addTask.ts
Normal file
185
src/commands/management/addTask.ts
Normal file
@ -0,0 +1,185 @@
|
|||||||
|
/**
|
||||||
|
* add command implementation
|
||||||
|
*
|
||||||
|
* Starts an AI conversation to refine task requirements,
|
||||||
|
* then creates a task file in .takt/tasks/ with YAML format.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import * as fs from 'node:fs';
|
||||||
|
import * as path from 'node:path';
|
||||||
|
import { stringify as stringifyYaml } from 'yaml';
|
||||||
|
import { promptInput, confirm, selectOption } from '../../prompt/index.js';
|
||||||
|
import { success, info } from '../../utils/ui.js';
|
||||||
|
import { summarizeTaskName } from '../../task/summarize.js';
|
||||||
|
import { loadGlobalConfig } from '../../config/globalConfig.js';
|
||||||
|
import { getProvider, type ProviderType } from '../../providers/index.js';
|
||||||
|
import { createLogger } from '../../utils/debug.js';
|
||||||
|
import { getErrorMessage } from '../../utils/error.js';
|
||||||
|
import { listWorkflows } from '../../config/workflowLoader.js';
|
||||||
|
import { getCurrentWorkflow } from '../../config/paths.js';
|
||||||
|
import { interactiveMode } from '../interactive.js';
|
||||||
|
import { isIssueReference, resolveIssueTask, parseIssueNumbers } from '../../github/issue.js';
|
||||||
|
import type { TaskFileData } from '../../task/schema.js';
|
||||||
|
|
||||||
|
const log = createLogger('add-task');
|
||||||
|
|
||||||
|
const SUMMARIZE_SYSTEM_PROMPT = `会話履歴からタスクの要件をまとめてください。
|
||||||
|
タスク実行エージェントへの指示として使われます。
|
||||||
|
具体的・簡潔に、必要な情報をすべて含めてください。
|
||||||
|
マークダウン形式で出力してください。`;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Summarize conversation history into a task description using AI.
|
||||||
|
*/
|
||||||
|
export async function summarizeConversation(cwd: string, conversationText: string): Promise<string> {
|
||||||
|
const globalConfig = loadGlobalConfig();
|
||||||
|
const providerType = (globalConfig.provider as ProviderType) ?? 'claude';
|
||||||
|
const provider = getProvider(providerType);
|
||||||
|
|
||||||
|
info('Summarizing task from conversation...');
|
||||||
|
|
||||||
|
const response = await provider.call('task-summarizer', conversationText, {
|
||||||
|
cwd,
|
||||||
|
maxTurns: 1,
|
||||||
|
allowedTools: [],
|
||||||
|
systemPrompt: SUMMARIZE_SYSTEM_PROMPT,
|
||||||
|
});
|
||||||
|
|
||||||
|
return response.content;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a unique task filename with AI-summarized slug
|
||||||
|
*/
|
||||||
|
async function generateFilename(tasksDir: string, taskContent: string, cwd: string): Promise<string> {
|
||||||
|
info('Generating task filename...');
|
||||||
|
const slug = await summarizeTaskName(taskContent, { cwd });
|
||||||
|
const base = slug || 'task';
|
||||||
|
let filename = `${base}.yaml`;
|
||||||
|
let counter = 1;
|
||||||
|
|
||||||
|
while (fs.existsSync(path.join(tasksDir, filename))) {
|
||||||
|
filename = `${base}-${counter}.yaml`;
|
||||||
|
counter++;
|
||||||
|
}
|
||||||
|
|
||||||
|
return filename;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* add command handler
|
||||||
|
*
|
||||||
|
* Flow:
|
||||||
|
* 1. AI対話モードでタスクを詰める
|
||||||
|
* 2. 会話履歴からAIがタスク要約を生成
|
||||||
|
* 3. 要約からファイル名をAIで生成
|
||||||
|
* 4. ワークツリー/ブランチ/ワークフロー設定
|
||||||
|
* 5. YAMLファイル作成
|
||||||
|
*/
|
||||||
|
export async function addTask(cwd: string, task?: string): Promise<void> {
|
||||||
|
const tasksDir = path.join(cwd, '.takt', 'tasks');
|
||||||
|
fs.mkdirSync(tasksDir, { recursive: true });
|
||||||
|
|
||||||
|
let taskContent: string;
|
||||||
|
let issueNumber: number | undefined;
|
||||||
|
|
||||||
|
if (task && isIssueReference(task)) {
|
||||||
|
// Issue reference: fetch issue and use directly as task content
|
||||||
|
info('Fetching GitHub Issue...');
|
||||||
|
try {
|
||||||
|
taskContent = resolveIssueTask(task);
|
||||||
|
const numbers = parseIssueNumbers([task]);
|
||||||
|
if (numbers.length > 0) {
|
||||||
|
issueNumber = numbers[0];
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
const msg = getErrorMessage(e);
|
||||||
|
log.error('Failed to fetch GitHub Issue', { task, error: msg });
|
||||||
|
info(`Failed to fetch issue ${task}: ${msg}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Interactive mode: AI conversation to refine task
|
||||||
|
const result = await interactiveMode(cwd);
|
||||||
|
if (!result.confirmed) {
|
||||||
|
info('Cancelled.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 会話履歴からタスク要約を生成
|
||||||
|
taskContent = await summarizeConversation(cwd, result.task);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. 要約からファイル名生成
|
||||||
|
const firstLine = taskContent.split('\n')[0] || taskContent;
|
||||||
|
const filename = await generateFilename(tasksDir, firstLine, cwd);
|
||||||
|
|
||||||
|
// 4. ワークツリー/ブランチ/ワークフロー設定
|
||||||
|
let worktree: boolean | string | undefined;
|
||||||
|
let branch: string | undefined;
|
||||||
|
let workflow: string | undefined;
|
||||||
|
|
||||||
|
const useWorktree = await confirm('Create worktree?', true);
|
||||||
|
if (useWorktree) {
|
||||||
|
const customPath = await promptInput('Worktree path (Enter for auto)');
|
||||||
|
worktree = customPath || true;
|
||||||
|
|
||||||
|
const customBranch = await promptInput('Branch name (Enter for auto)');
|
||||||
|
if (customBranch) {
|
||||||
|
branch = customBranch;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const availableWorkflows = listWorkflows(cwd);
|
||||||
|
if (availableWorkflows.length > 0) {
|
||||||
|
const currentWorkflow = getCurrentWorkflow(cwd);
|
||||||
|
const defaultWorkflow = availableWorkflows.includes(currentWorkflow)
|
||||||
|
? currentWorkflow
|
||||||
|
: availableWorkflows[0]!;
|
||||||
|
const options = availableWorkflows.map((name) => ({
|
||||||
|
label: name === currentWorkflow ? `${name} (current)` : name,
|
||||||
|
value: name,
|
||||||
|
}));
|
||||||
|
const selected = await selectOption('Select workflow:', options);
|
||||||
|
if (selected === null) {
|
||||||
|
info('Cancelled.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (selected !== defaultWorkflow) {
|
||||||
|
workflow = selected;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. YAMLファイル作成
|
||||||
|
const taskData: TaskFileData = { task: taskContent };
|
||||||
|
if (worktree !== undefined) {
|
||||||
|
taskData.worktree = worktree;
|
||||||
|
}
|
||||||
|
if (branch) {
|
||||||
|
taskData.branch = branch;
|
||||||
|
}
|
||||||
|
if (workflow) {
|
||||||
|
taskData.workflow = workflow;
|
||||||
|
}
|
||||||
|
if (issueNumber !== undefined) {
|
||||||
|
taskData.issue = issueNumber;
|
||||||
|
}
|
||||||
|
|
||||||
|
const filePath = path.join(tasksDir, filename);
|
||||||
|
const yamlContent = stringifyYaml(taskData);
|
||||||
|
fs.writeFileSync(filePath, yamlContent, 'utf-8');
|
||||||
|
|
||||||
|
log.info('Task created', { filePath, taskData });
|
||||||
|
|
||||||
|
success(`Task created: ${filename}`);
|
||||||
|
info(` Path: ${filePath}`);
|
||||||
|
if (worktree) {
|
||||||
|
info(` Worktree: ${typeof worktree === 'string' ? worktree : 'auto'}`);
|
||||||
|
}
|
||||||
|
if (branch) {
|
||||||
|
info(` Branch: ${branch}`);
|
||||||
|
}
|
||||||
|
if (workflow) {
|
||||||
|
info(` Workflow: ${workflow}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
134
src/commands/management/config.ts
Normal file
134
src/commands/management/config.ts
Normal file
@ -0,0 +1,134 @@
|
|||||||
|
/**
|
||||||
|
* Config switching command (like workflow switching)
|
||||||
|
*
|
||||||
|
* Permission mode selection that works from CLI.
|
||||||
|
* Uses selectOption for prompt selection, same pattern as switchWorkflow.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import chalk from 'chalk';
|
||||||
|
import { info, success } from '../../utils/ui.js';
|
||||||
|
import { selectOption } from '../../prompt/index.js';
|
||||||
|
import {
|
||||||
|
loadProjectConfig,
|
||||||
|
updateProjectConfig,
|
||||||
|
type PermissionMode,
|
||||||
|
} from '../../config/projectConfig.js';
|
||||||
|
|
||||||
|
// Re-export for convenience
|
||||||
|
export type { PermissionMode } from '../../config/projectConfig.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get permission mode options for selection
|
||||||
|
*/
|
||||||
|
/** Common permission mode option definitions */
|
||||||
|
export const PERMISSION_MODE_OPTIONS: {
|
||||||
|
key: PermissionMode;
|
||||||
|
label: string;
|
||||||
|
description: string;
|
||||||
|
details: string[];
|
||||||
|
icon: string;
|
||||||
|
}[] = [
|
||||||
|
{
|
||||||
|
key: 'default',
|
||||||
|
label: 'デフォルト (default)',
|
||||||
|
description: 'Agent SDK標準モード(ファイル編集自動承認、最小限の確認)',
|
||||||
|
details: [
|
||||||
|
'Claude Agent SDKの標準設定(acceptEdits)を使用',
|
||||||
|
'ファイル編集は自動承認され、確認プロンプトなしで実行',
|
||||||
|
'Bash等の危険な操作は権限確認が表示される',
|
||||||
|
'通常の開発作業に推奨',
|
||||||
|
],
|
||||||
|
icon: '📋',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'sacrifice-my-pc',
|
||||||
|
label: 'SACRIFICE-MY-PC',
|
||||||
|
description: '全ての権限リクエストが自動承認されます',
|
||||||
|
details: [
|
||||||
|
'⚠️ 警告: 全ての操作が確認なしで実行されます',
|
||||||
|
'Bash, ファイル削除, システム操作も自動承認',
|
||||||
|
'ブロック状態(判断待ち)も自動スキップ',
|
||||||
|
'完全自動化が必要な場合のみ使用してください',
|
||||||
|
],
|
||||||
|
icon: '💀',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function getPermissionModeOptions(currentMode: PermissionMode): {
|
||||||
|
label: string;
|
||||||
|
value: PermissionMode;
|
||||||
|
description: string;
|
||||||
|
details: string[];
|
||||||
|
}[] {
|
||||||
|
return PERMISSION_MODE_OPTIONS.map((opt) => ({
|
||||||
|
label: currentMode === opt.key
|
||||||
|
? (opt.key === 'sacrifice-my-pc' ? chalk.red : chalk.blue)(`${opt.icon} ${opt.label}`) + ' (current)'
|
||||||
|
: (opt.key === 'sacrifice-my-pc' ? chalk.red : chalk.blue)(`${opt.icon} ${opt.label}`),
|
||||||
|
value: opt.key,
|
||||||
|
description: opt.description,
|
||||||
|
details: opt.details,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current permission mode from project config
|
||||||
|
*/
|
||||||
|
export function getCurrentPermissionMode(cwd: string): PermissionMode {
|
||||||
|
const config = loadProjectConfig(cwd);
|
||||||
|
if (config.permissionMode) {
|
||||||
|
return config.permissionMode as PermissionMode;
|
||||||
|
}
|
||||||
|
return 'default';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set permission mode in project config
|
||||||
|
*/
|
||||||
|
export function setPermissionMode(cwd: string, mode: PermissionMode): void {
|
||||||
|
updateProjectConfig(cwd, 'permissionMode', mode);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Switch permission mode (like switchWorkflow)
|
||||||
|
* @returns true if switch was successful
|
||||||
|
*/
|
||||||
|
export async function switchConfig(cwd: string, modeName?: string): Promise<boolean> {
|
||||||
|
const currentMode = getCurrentPermissionMode(cwd);
|
||||||
|
|
||||||
|
// No mode specified - show selection prompt
|
||||||
|
if (!modeName) {
|
||||||
|
info(`Current mode: ${currentMode}`);
|
||||||
|
|
||||||
|
const options = getPermissionModeOptions(currentMode);
|
||||||
|
const selected = await selectOption('Select permission mode:', options);
|
||||||
|
|
||||||
|
if (!selected) {
|
||||||
|
info('Cancelled');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
modeName = selected;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate mode name
|
||||||
|
if (modeName !== 'default' && modeName !== 'sacrifice-my-pc') {
|
||||||
|
info(`Invalid mode: ${modeName}`);
|
||||||
|
info('Available modes: default, sacrifice-my-pc');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const finalMode: PermissionMode = modeName as PermissionMode;
|
||||||
|
|
||||||
|
// Save to project config
|
||||||
|
setPermissionMode(cwd, finalMode);
|
||||||
|
|
||||||
|
if (finalMode === 'sacrifice-my-pc') {
|
||||||
|
success('Switched to: sacrifice-my-pc 💀');
|
||||||
|
info('All permission requests will be auto-approved.');
|
||||||
|
} else {
|
||||||
|
success('Switched to: default 📋');
|
||||||
|
info('Using Agent SDK default mode (acceptEdits - minimal permission prompts).');
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
124
src/commands/management/eject.ts
Normal file
124
src/commands/management/eject.ts
Normal file
@ -0,0 +1,124 @@
|
|||||||
|
/**
|
||||||
|
* /eject command implementation
|
||||||
|
*
|
||||||
|
* Copies a builtin workflow (and its agents) to ~/.takt/ for user customization.
|
||||||
|
* Once ejected, the user copy takes priority over the builtin version.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { existsSync, readdirSync, statSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
||||||
|
import { join, dirname } from 'node:path';
|
||||||
|
import { getGlobalWorkflowsDir, getGlobalAgentsDir, getBuiltinWorkflowsDir, getBuiltinAgentsDir } from '../../config/paths.js';
|
||||||
|
import { getLanguage } from '../../config/globalConfig.js';
|
||||||
|
import { header, success, info, warn, error, blankLine } from '../../utils/ui.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Eject a builtin workflow to user space for customization.
|
||||||
|
* Copies the workflow YAML and related agent .md files to ~/.takt/.
|
||||||
|
* Agent paths in the ejected workflow are rewritten from ../agents/ to ~/.takt/agents/.
|
||||||
|
*/
|
||||||
|
export async function ejectBuiltin(name?: string): Promise<void> {
|
||||||
|
header('Eject Builtin');
|
||||||
|
|
||||||
|
const lang = getLanguage();
|
||||||
|
const builtinWorkflowsDir = getBuiltinWorkflowsDir(lang);
|
||||||
|
|
||||||
|
if (!name) {
|
||||||
|
// List available builtins
|
||||||
|
listAvailableBuiltins(builtinWorkflowsDir);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const builtinPath = join(builtinWorkflowsDir, `${name}.yaml`);
|
||||||
|
if (!existsSync(builtinPath)) {
|
||||||
|
error(`Builtin workflow not found: ${name}`);
|
||||||
|
info('Run "takt eject" to see available builtins.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userWorkflowsDir = getGlobalWorkflowsDir();
|
||||||
|
const userAgentsDir = getGlobalAgentsDir();
|
||||||
|
const builtinAgentsDir = getBuiltinAgentsDir(lang);
|
||||||
|
|
||||||
|
// Copy workflow YAML (rewrite agent paths)
|
||||||
|
const workflowDest = join(userWorkflowsDir, `${name}.yaml`);
|
||||||
|
if (existsSync(workflowDest)) {
|
||||||
|
warn(`User workflow already exists: ${workflowDest}`);
|
||||||
|
warn('Skipping workflow copy (user version takes priority).');
|
||||||
|
} else {
|
||||||
|
mkdirSync(dirname(workflowDest), { recursive: true });
|
||||||
|
const content = readFileSync(builtinPath, 'utf-8');
|
||||||
|
// Rewrite relative agent paths to ~/.takt/agents/
|
||||||
|
const rewritten = content.replace(
|
||||||
|
/agent:\s*\.\.\/agents\//g,
|
||||||
|
'agent: ~/.takt/agents/',
|
||||||
|
);
|
||||||
|
writeFileSync(workflowDest, rewritten, 'utf-8');
|
||||||
|
success(`Ejected workflow: ${workflowDest}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy related agent files
|
||||||
|
const agentPaths = extractAgentRelativePaths(builtinPath);
|
||||||
|
let copiedAgents = 0;
|
||||||
|
|
||||||
|
for (const relPath of agentPaths) {
|
||||||
|
const srcPath = join(builtinAgentsDir, relPath);
|
||||||
|
const destPath = join(userAgentsDir, relPath);
|
||||||
|
|
||||||
|
if (!existsSync(srcPath)) continue;
|
||||||
|
|
||||||
|
if (existsSync(destPath)) {
|
||||||
|
info(` Agent already exists: ${destPath}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
mkdirSync(dirname(destPath), { recursive: true });
|
||||||
|
writeFileSync(destPath, readFileSync(srcPath));
|
||||||
|
info(` ✓ ${destPath}`);
|
||||||
|
copiedAgents++;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (copiedAgents > 0) {
|
||||||
|
success(`${copiedAgents} agent file(s) ejected.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** List available builtin workflows for ejection */
|
||||||
|
function listAvailableBuiltins(builtinWorkflowsDir: string): void {
|
||||||
|
if (!existsSync(builtinWorkflowsDir)) {
|
||||||
|
warn('No builtin workflows found.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
info('Available builtin workflows:');
|
||||||
|
blankLine();
|
||||||
|
|
||||||
|
for (const entry of readdirSync(builtinWorkflowsDir).sort()) {
|
||||||
|
if (!entry.endsWith('.yaml') && !entry.endsWith('.yml')) continue;
|
||||||
|
if (!statSync(join(builtinWorkflowsDir, entry)).isFile()) continue;
|
||||||
|
|
||||||
|
const name = entry.replace(/\.ya?ml$/, '');
|
||||||
|
info(` ${name}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
blankLine();
|
||||||
|
info('Usage: takt eject {name}');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract agent relative paths from a builtin workflow YAML.
|
||||||
|
* Matches `agent: ../agents/{path}` and returns the {path} portions.
|
||||||
|
*/
|
||||||
|
function extractAgentRelativePaths(workflowPath: string): string[] {
|
||||||
|
const content = readFileSync(workflowPath, 'utf-8');
|
||||||
|
const paths = new Set<string>();
|
||||||
|
const regex = /agent:\s*\.\.\/agents\/(.+)/g;
|
||||||
|
|
||||||
|
let match: RegExpExecArray | null;
|
||||||
|
while ((match = regex.exec(content)) !== null) {
|
||||||
|
if (match[1]) {
|
||||||
|
paths.add(match[1].trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(paths);
|
||||||
|
}
|
||||||
10
src/commands/management/index.ts
Normal file
10
src/commands/management/index.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
/**
|
||||||
|
* Task/workflow management commands.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { addTask, summarizeConversation } from './addTask.js';
|
||||||
|
export { listTasks, isBranchMerged, showFullDiff, type ListAction } from './listTasks.js';
|
||||||
|
export { watchTasks } from './watchTasks.js';
|
||||||
|
export { switchConfig, getCurrentPermissionMode, setPermissionMode, type PermissionMode } from './config.js';
|
||||||
|
export { ejectBuiltin } from './eject.js';
|
||||||
|
export { switchWorkflow } from './workflow.js';
|
||||||
441
src/commands/management/listTasks.ts
Normal file
441
src/commands/management/listTasks.ts
Normal file
@ -0,0 +1,441 @@
|
|||||||
|
/**
|
||||||
|
* List tasks command
|
||||||
|
*
|
||||||
|
* Interactive UI for reviewing branch-based task results:
|
||||||
|
* try merge, merge & cleanup, or delete actions.
|
||||||
|
* Clones are ephemeral — only branches persist between sessions.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { execFileSync, spawnSync } from 'node:child_process';
|
||||||
|
import chalk from 'chalk';
|
||||||
|
import {
|
||||||
|
createTempCloneForBranch,
|
||||||
|
removeClone,
|
||||||
|
removeCloneMeta,
|
||||||
|
cleanupOrphanedClone,
|
||||||
|
} from '../../task/clone.js';
|
||||||
|
import {
|
||||||
|
detectDefaultBranch,
|
||||||
|
listTaktBranches,
|
||||||
|
buildListItems,
|
||||||
|
type BranchListItem,
|
||||||
|
} from '../../task/branchList.js';
|
||||||
|
import { autoCommitAndPush } from '../../task/autoCommit.js';
|
||||||
|
import { selectOption, confirm, promptInput } from '../../prompt/index.js';
|
||||||
|
import { info, success, error as logError, warn, header, blankLine } from '../../utils/ui.js';
|
||||||
|
import { createLogger } from '../../utils/debug.js';
|
||||||
|
import { getErrorMessage } from '../../utils/error.js';
|
||||||
|
import { executeTask, type TaskExecutionOptions } from '../taskExecution.js';
|
||||||
|
import { listWorkflows } from '../../config/workflowLoader.js';
|
||||||
|
import { getCurrentWorkflow } from '../../config/paths.js';
|
||||||
|
import { DEFAULT_WORKFLOW_NAME } from '../../constants.js';
|
||||||
|
|
||||||
|
const log = createLogger('list-tasks');
|
||||||
|
|
||||||
|
/** Actions available for a listed branch */
|
||||||
|
export type ListAction = 'diff' | 'instruct' | 'try' | 'merge' | 'delete';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a branch has already been merged into HEAD.
|
||||||
|
*/
|
||||||
|
export function isBranchMerged(projectDir: string, branch: string): boolean {
|
||||||
|
try {
|
||||||
|
execFileSync('git', ['merge-base', '--is-ancestor', branch, 'HEAD'], {
|
||||||
|
cwd: projectDir,
|
||||||
|
encoding: 'utf-8',
|
||||||
|
stdio: 'pipe',
|
||||||
|
});
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show full diff in an interactive pager (less).
|
||||||
|
* Falls back to direct output if pager is unavailable.
|
||||||
|
*/
|
||||||
|
export function showFullDiff(
|
||||||
|
cwd: string,
|
||||||
|
defaultBranch: string,
|
||||||
|
branch: string,
|
||||||
|
): void {
|
||||||
|
try {
|
||||||
|
const result = spawnSync(
|
||||||
|
'git', ['diff', '--color=always', `${defaultBranch}...${branch}`],
|
||||||
|
{ cwd, stdio: ['inherit', 'inherit', 'inherit'], env: { ...process.env, GIT_PAGER: 'less -R' } },
|
||||||
|
);
|
||||||
|
if (result.status !== 0) {
|
||||||
|
warn('Could not display diff');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
warn('Could not display diff');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show diff stat for a branch and prompt for an action.
|
||||||
|
*/
|
||||||
|
async function showDiffAndPromptAction(
|
||||||
|
cwd: string,
|
||||||
|
defaultBranch: string,
|
||||||
|
item: BranchListItem,
|
||||||
|
): Promise<ListAction | null> {
|
||||||
|
header(item.info.branch);
|
||||||
|
if (item.originalInstruction) {
|
||||||
|
console.log(chalk.dim(` ${item.originalInstruction}`));
|
||||||
|
}
|
||||||
|
blankLine();
|
||||||
|
|
||||||
|
// Show diff stat
|
||||||
|
try {
|
||||||
|
const stat = execFileSync(
|
||||||
|
'git', ['diff', '--stat', `${defaultBranch}...${item.info.branch}`],
|
||||||
|
{ cwd, encoding: 'utf-8', stdio: 'pipe' },
|
||||||
|
);
|
||||||
|
console.log(stat);
|
||||||
|
} catch {
|
||||||
|
warn('Could not generate diff stat');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Prompt action
|
||||||
|
const action = await selectOption<ListAction>(
|
||||||
|
`Action for ${item.info.branch}:`,
|
||||||
|
[
|
||||||
|
{ label: 'View diff', value: 'diff', description: 'Show full diff in pager' },
|
||||||
|
{ label: 'Instruct', value: 'instruct', description: 'Give additional instructions via temp clone' },
|
||||||
|
{ label: 'Try merge', value: 'try', description: 'Squash merge (stage changes without commit)' },
|
||||||
|
{ label: 'Merge & cleanup', value: 'merge', description: 'Merge and delete branch' },
|
||||||
|
{ label: 'Delete', value: 'delete', description: 'Discard changes, delete branch' },
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
return action;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Try-merge (squash): stage changes from branch without committing.
|
||||||
|
* User can inspect staged changes and commit manually if satisfied.
|
||||||
|
*/
|
||||||
|
export function tryMergeBranch(projectDir: string, item: BranchListItem): boolean {
|
||||||
|
const { branch } = item.info;
|
||||||
|
|
||||||
|
try {
|
||||||
|
execFileSync('git', ['merge', '--squash', branch], {
|
||||||
|
cwd: projectDir,
|
||||||
|
encoding: 'utf-8',
|
||||||
|
stdio: 'pipe',
|
||||||
|
});
|
||||||
|
|
||||||
|
success(`Squash-merged ${branch} (changes staged, not committed)`);
|
||||||
|
info('Run `git status` to see staged changes, `git commit` to finalize, or `git reset` to undo.');
|
||||||
|
log.info('Try-merge (squash) completed', { branch });
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
const msg = getErrorMessage(err);
|
||||||
|
logError(`Squash merge failed: ${msg}`);
|
||||||
|
logError('You may need to resolve conflicts manually.');
|
||||||
|
log.error('Try-merge (squash) failed', { branch, error: msg });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge & cleanup: if already merged, skip merge and just delete the branch.
|
||||||
|
* Otherwise merge first, then delete the branch.
|
||||||
|
* No worktree removal needed — clones are ephemeral.
|
||||||
|
*/
|
||||||
|
export function mergeBranch(projectDir: string, item: BranchListItem): boolean {
|
||||||
|
const { branch } = item.info;
|
||||||
|
const alreadyMerged = isBranchMerged(projectDir, branch);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Merge only if not already merged
|
||||||
|
if (alreadyMerged) {
|
||||||
|
info(`${branch} is already merged, skipping merge.`);
|
||||||
|
log.info('Branch already merged, cleanup only', { branch });
|
||||||
|
} else {
|
||||||
|
execFileSync('git', ['merge', branch], {
|
||||||
|
cwd: projectDir,
|
||||||
|
encoding: 'utf-8',
|
||||||
|
stdio: 'pipe',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete the branch
|
||||||
|
try {
|
||||||
|
execFileSync('git', ['branch', '-d', branch], {
|
||||||
|
cwd: projectDir,
|
||||||
|
encoding: 'utf-8',
|
||||||
|
stdio: 'pipe',
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
warn(`Could not delete branch ${branch}. You may delete it manually.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean up orphaned clone directory if it still exists
|
||||||
|
cleanupOrphanedClone(projectDir, branch);
|
||||||
|
|
||||||
|
success(`Merged & cleaned up ${branch}`);
|
||||||
|
log.info('Branch merged & cleaned up', { branch, alreadyMerged });
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
const msg = getErrorMessage(err);
|
||||||
|
logError(`Merge failed: ${msg}`);
|
||||||
|
logError('You may need to resolve conflicts manually.');
|
||||||
|
log.error('Merge & cleanup failed', { branch, error: msg });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a branch (discard changes).
|
||||||
|
* No worktree removal needed — clones are ephemeral.
|
||||||
|
*/
|
||||||
|
export function deleteBranch(projectDir: string, item: BranchListItem): boolean {
|
||||||
|
const { branch } = item.info;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Force-delete the branch
|
||||||
|
execFileSync('git', ['branch', '-D', branch], {
|
||||||
|
cwd: projectDir,
|
||||||
|
encoding: 'utf-8',
|
||||||
|
stdio: 'pipe',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Clean up orphaned clone directory if it still exists
|
||||||
|
cleanupOrphanedClone(projectDir, branch);
|
||||||
|
|
||||||
|
success(`Deleted ${branch}`);
|
||||||
|
log.info('Branch deleted', { branch });
|
||||||
|
return true;
|
||||||
|
} catch (err) {
|
||||||
|
const msg = getErrorMessage(err);
|
||||||
|
logError(`Delete failed: ${msg}`);
|
||||||
|
log.error('Delete failed', { branch, error: msg });
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the workflow to use for instruction.
|
||||||
|
* If multiple workflows available, prompt user to select.
|
||||||
|
*/
|
||||||
|
async function selectWorkflowForInstruction(projectDir: string): Promise<string | null> {
|
||||||
|
const availableWorkflows = listWorkflows(projectDir);
|
||||||
|
const currentWorkflow = getCurrentWorkflow(projectDir);
|
||||||
|
|
||||||
|
if (availableWorkflows.length === 0) {
|
||||||
|
return DEFAULT_WORKFLOW_NAME;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (availableWorkflows.length === 1 && availableWorkflows[0]) {
|
||||||
|
return availableWorkflows[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Multiple workflows: let user select
|
||||||
|
const options = availableWorkflows.map((name) => ({
|
||||||
|
label: name === currentWorkflow ? `${name} (current)` : name,
|
||||||
|
value: name,
|
||||||
|
}));
|
||||||
|
|
||||||
|
return await selectOption('Select workflow:', options);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get branch context: diff stat and commit log from main branch.
|
||||||
|
*/
|
||||||
|
function getBranchContext(projectDir: string, branch: string): string {
|
||||||
|
const defaultBranch = detectDefaultBranch(projectDir);
|
||||||
|
const lines: string[] = [];
|
||||||
|
|
||||||
|
// Get diff stat
|
||||||
|
try {
|
||||||
|
const diffStat = execFileSync(
|
||||||
|
'git', ['diff', '--stat', `${defaultBranch}...${branch}`],
|
||||||
|
{ cwd: projectDir, encoding: 'utf-8', stdio: 'pipe' }
|
||||||
|
).trim();
|
||||||
|
if (diffStat) {
|
||||||
|
lines.push('## 現在の変更内容(mainからの差分)');
|
||||||
|
lines.push('```');
|
||||||
|
lines.push(diffStat);
|
||||||
|
lines.push('```');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore errors
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get commit log
|
||||||
|
try {
|
||||||
|
const commitLog = execFileSync(
|
||||||
|
'git', ['log', '--oneline', `${defaultBranch}..${branch}`],
|
||||||
|
{ cwd: projectDir, encoding: 'utf-8', stdio: 'pipe' }
|
||||||
|
).trim();
|
||||||
|
if (commitLog) {
|
||||||
|
lines.push('');
|
||||||
|
lines.push('## コミット履歴');
|
||||||
|
lines.push('```');
|
||||||
|
lines.push(commitLog);
|
||||||
|
lines.push('```');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore errors
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.length > 0 ? lines.join('\n') + '\n\n' : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Instruct branch: create a temp clone, give additional instructions,
|
||||||
|
* auto-commit+push, then remove clone.
|
||||||
|
*/
|
||||||
|
export async function instructBranch(
|
||||||
|
projectDir: string,
|
||||||
|
item: BranchListItem,
|
||||||
|
options?: TaskExecutionOptions,
|
||||||
|
): Promise<boolean> {
|
||||||
|
const { branch } = item.info;
|
||||||
|
|
||||||
|
// 1. Prompt for instruction
|
||||||
|
const instruction = await promptInput('Enter instruction');
|
||||||
|
if (!instruction) {
|
||||||
|
info('Cancelled');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Select workflow
|
||||||
|
const selectedWorkflow = await selectWorkflowForInstruction(projectDir);
|
||||||
|
if (!selectedWorkflow) {
|
||||||
|
info('Cancelled');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
log.info('Instructing branch via temp clone', { branch, workflow: selectedWorkflow });
|
||||||
|
info(`Running instruction on ${branch}...`);
|
||||||
|
|
||||||
|
// 3. Create temp clone for the branch
|
||||||
|
const clone = createTempCloneForBranch(projectDir, branch);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 4. Build instruction with branch context
|
||||||
|
const branchContext = getBranchContext(projectDir, branch);
|
||||||
|
const fullInstruction = branchContext
|
||||||
|
? `${branchContext}## 追加指示\n${instruction}`
|
||||||
|
: instruction;
|
||||||
|
|
||||||
|
// 5. Execute task on temp clone
|
||||||
|
const taskSuccess = await executeTask({
|
||||||
|
task: fullInstruction,
|
||||||
|
cwd: clone.path,
|
||||||
|
workflowIdentifier: selectedWorkflow,
|
||||||
|
projectCwd: projectDir,
|
||||||
|
agentOverrides: options,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 6. Auto-commit+push if successful
|
||||||
|
if (taskSuccess) {
|
||||||
|
const commitResult = autoCommitAndPush(clone.path, item.taskSlug, projectDir);
|
||||||
|
if (commitResult.success && commitResult.commitHash) {
|
||||||
|
info(`Auto-committed & pushed: ${commitResult.commitHash}`);
|
||||||
|
} else if (!commitResult.success) {
|
||||||
|
warn(`Auto-commit skipped: ${commitResult.message}`);
|
||||||
|
}
|
||||||
|
success(`Instruction completed on ${branch}`);
|
||||||
|
log.info('Instruction completed', { branch });
|
||||||
|
} else {
|
||||||
|
logError(`Instruction failed on ${branch}`);
|
||||||
|
log.error('Instruction failed', { branch });
|
||||||
|
}
|
||||||
|
|
||||||
|
return taskSuccess;
|
||||||
|
} finally {
|
||||||
|
// 7. Always remove temp clone and metadata
|
||||||
|
removeClone(clone.path);
|
||||||
|
removeCloneMeta(projectDir, branch);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Main entry point: list branch-based tasks interactively.
|
||||||
|
*/
|
||||||
|
export async function listTasks(cwd: string, options?: TaskExecutionOptions): Promise<void> {
|
||||||
|
log.info('Starting list-tasks');
|
||||||
|
|
||||||
|
const defaultBranch = detectDefaultBranch(cwd);
|
||||||
|
let branches = listTaktBranches(cwd);
|
||||||
|
|
||||||
|
if (branches.length === 0) {
|
||||||
|
info('No tasks to list.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Interactive loop
|
||||||
|
while (branches.length > 0) {
|
||||||
|
const items = buildListItems(cwd, branches, defaultBranch);
|
||||||
|
|
||||||
|
// Build selection options
|
||||||
|
const menuOptions = items.map((item, idx) => {
|
||||||
|
const filesSummary = `${item.filesChanged} file${item.filesChanged !== 1 ? 's' : ''} changed`;
|
||||||
|
const description = item.originalInstruction
|
||||||
|
? `${filesSummary} | ${item.originalInstruction}`
|
||||||
|
: filesSummary;
|
||||||
|
return {
|
||||||
|
label: item.info.branch,
|
||||||
|
value: String(idx),
|
||||||
|
description,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const selected = await selectOption<string>(
|
||||||
|
'List Tasks (Branches)',
|
||||||
|
menuOptions,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (selected === null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectedIdx = parseInt(selected, 10);
|
||||||
|
const item = items[selectedIdx];
|
||||||
|
if (!item) continue;
|
||||||
|
|
||||||
|
// Action loop: re-show menu after viewing diff
|
||||||
|
let action: ListAction | null;
|
||||||
|
do {
|
||||||
|
action = await showDiffAndPromptAction(cwd, defaultBranch, item);
|
||||||
|
|
||||||
|
if (action === 'diff') {
|
||||||
|
showFullDiff(cwd, defaultBranch, item.info.branch);
|
||||||
|
}
|
||||||
|
} while (action === 'diff');
|
||||||
|
|
||||||
|
if (action === null) continue;
|
||||||
|
|
||||||
|
switch (action) {
|
||||||
|
case 'instruct':
|
||||||
|
await instructBranch(cwd, item, options);
|
||||||
|
break;
|
||||||
|
case 'try':
|
||||||
|
tryMergeBranch(cwd, item);
|
||||||
|
break;
|
||||||
|
case 'merge':
|
||||||
|
mergeBranch(cwd, item);
|
||||||
|
break;
|
||||||
|
case 'delete': {
|
||||||
|
const confirmed = await confirm(
|
||||||
|
`Delete ${item.info.branch}? This will discard all changes.`,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
if (confirmed) {
|
||||||
|
deleteBranch(cwd, item);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh branch list after action
|
||||||
|
branches = listTaktBranches(cwd);
|
||||||
|
}
|
||||||
|
|
||||||
|
info('All tasks listed.');
|
||||||
|
}
|
||||||
83
src/commands/management/watchTasks.ts
Normal file
83
src/commands/management/watchTasks.ts
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
/**
|
||||||
|
* /watch command implementation
|
||||||
|
*
|
||||||
|
* Watches .takt/tasks/ for new task files and executes them automatically.
|
||||||
|
* Stays resident until Ctrl+C (SIGINT).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { TaskRunner, type TaskInfo } from '../../task/index.js';
|
||||||
|
import { TaskWatcher } from '../../task/watcher.js';
|
||||||
|
import { getCurrentWorkflow } from '../../config/paths.js';
|
||||||
|
import {
|
||||||
|
header,
|
||||||
|
info,
|
||||||
|
success,
|
||||||
|
status,
|
||||||
|
blankLine,
|
||||||
|
} from '../../utils/ui.js';
|
||||||
|
import { executeAndCompleteTask } from '../taskExecution.js';
|
||||||
|
import { DEFAULT_WORKFLOW_NAME } from '../../constants.js';
|
||||||
|
import type { TaskExecutionOptions } from '../taskExecution.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Watch for tasks and execute them as they appear.
|
||||||
|
* Runs until Ctrl+C.
|
||||||
|
*/
|
||||||
|
export async function watchTasks(cwd: string, options?: TaskExecutionOptions): Promise<void> {
|
||||||
|
const workflowName = getCurrentWorkflow(cwd) || DEFAULT_WORKFLOW_NAME;
|
||||||
|
const taskRunner = new TaskRunner(cwd);
|
||||||
|
const watcher = new TaskWatcher(cwd);
|
||||||
|
|
||||||
|
let taskCount = 0;
|
||||||
|
let successCount = 0;
|
||||||
|
let failCount = 0;
|
||||||
|
|
||||||
|
header('TAKT Watch Mode');
|
||||||
|
info(`Workflow: ${workflowName}`);
|
||||||
|
info(`Watching: ${taskRunner.getTasksDir()}`);
|
||||||
|
info('Waiting for tasks... (Ctrl+C to stop)');
|
||||||
|
blankLine();
|
||||||
|
|
||||||
|
// Graceful shutdown on SIGINT
|
||||||
|
const onSigInt = () => {
|
||||||
|
blankLine();
|
||||||
|
info('Stopping watch...');
|
||||||
|
watcher.stop();
|
||||||
|
};
|
||||||
|
process.on('SIGINT', onSigInt);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await watcher.watch(async (task: TaskInfo) => {
|
||||||
|
taskCount++;
|
||||||
|
blankLine();
|
||||||
|
info(`=== Task ${taskCount}: ${task.name} ===`);
|
||||||
|
blankLine();
|
||||||
|
|
||||||
|
const taskSuccess = await executeAndCompleteTask(task, taskRunner, cwd, workflowName, options);
|
||||||
|
|
||||||
|
if (taskSuccess) {
|
||||||
|
successCount++;
|
||||||
|
} else {
|
||||||
|
failCount++;
|
||||||
|
}
|
||||||
|
|
||||||
|
blankLine();
|
||||||
|
info('Waiting for tasks... (Ctrl+C to stop)');
|
||||||
|
});
|
||||||
|
} finally {
|
||||||
|
process.removeListener('SIGINT', onSigInt);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Summary on exit
|
||||||
|
if (taskCount > 0) {
|
||||||
|
blankLine();
|
||||||
|
header('Watch Summary');
|
||||||
|
status('Total', String(taskCount));
|
||||||
|
status('Success', String(successCount), successCount === taskCount ? 'green' : undefined);
|
||||||
|
if (failCount > 0) {
|
||||||
|
status('Failed', String(failCount), 'red');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
success('Watch stopped.');
|
||||||
|
}
|
||||||
63
src/commands/management/workflow.ts
Normal file
63
src/commands/management/workflow.ts
Normal file
@ -0,0 +1,63 @@
|
|||||||
|
/**
|
||||||
|
* Workflow switching command
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { listWorkflows, loadWorkflow } from '../../config/index.js';
|
||||||
|
import { getCurrentWorkflow, setCurrentWorkflow } from '../../config/paths.js';
|
||||||
|
import { info, success, error } from '../../utils/ui.js';
|
||||||
|
import { selectOption } from '../../prompt/index.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all available workflow options
|
||||||
|
*/
|
||||||
|
function getAllWorkflowOptions(cwd: string): { label: string; value: string }[] {
|
||||||
|
const current = getCurrentWorkflow(cwd);
|
||||||
|
const workflows = listWorkflows(cwd);
|
||||||
|
|
||||||
|
const options: { label: string; value: string }[] = [];
|
||||||
|
|
||||||
|
// Add all workflows
|
||||||
|
for (const name of workflows) {
|
||||||
|
const isCurrent = name === current;
|
||||||
|
const label = isCurrent ? `${name} (current)` : name;
|
||||||
|
options.push({ label, value: name });
|
||||||
|
}
|
||||||
|
|
||||||
|
return options;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Switch to a different workflow
|
||||||
|
* @returns true if switch was successful
|
||||||
|
*/
|
||||||
|
export async function switchWorkflow(cwd: string, workflowName?: string): Promise<boolean> {
|
||||||
|
// No workflow specified - show selection prompt
|
||||||
|
if (!workflowName) {
|
||||||
|
const current = getCurrentWorkflow(cwd);
|
||||||
|
info(`Current workflow: ${current}`);
|
||||||
|
|
||||||
|
const options = getAllWorkflowOptions(cwd);
|
||||||
|
const selected = await selectOption('Select workflow:', options);
|
||||||
|
|
||||||
|
if (!selected) {
|
||||||
|
info('Cancelled');
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
workflowName = selected;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if workflow exists
|
||||||
|
const config = loadWorkflow(workflowName, cwd);
|
||||||
|
|
||||||
|
if (!config) {
|
||||||
|
error(`Workflow "${workflowName}" not found`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save to project config
|
||||||
|
setCurrentWorkflow(cwd, workflowName);
|
||||||
|
success(`Switched to workflow: ${workflowName}`);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
@ -1,243 +1,2 @@
|
|||||||
/**
|
/** Re-export shim — actual implementation in execution/pipelineExecution.ts */
|
||||||
* Pipeline execution flow
|
export { executePipeline, type PipelineExecutionOptions } from './execution/pipelineExecution.js';
|
||||||
*
|
|
||||||
* Orchestrates the full pipeline:
|
|
||||||
* 1. Fetch issue content
|
|
||||||
* 2. Create branch
|
|
||||||
* 3. Run workflow
|
|
||||||
* 4. Commit & push
|
|
||||||
* 5. Create PR
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { execFileSync } from 'node:child_process';
|
|
||||||
import { fetchIssue, formatIssueAsTask, checkGhCli, type GitHubIssue } from '../github/issue.js';
|
|
||||||
import { createPullRequest, pushBranch, buildPrBody } from '../github/pr.js';
|
|
||||||
import { stageAndCommit } from '../task/git.js';
|
|
||||||
import { executeTask, type TaskExecutionOptions } from './taskExecution.js';
|
|
||||||
import { loadGlobalConfig } from '../config/globalConfig.js';
|
|
||||||
import { info, error, success, status, blankLine } from '../utils/ui.js';
|
|
||||||
import { createLogger } from '../utils/debug.js';
|
|
||||||
import { getErrorMessage } from '../utils/error.js';
|
|
||||||
import type { PipelineConfig } from '../models/types.js';
|
|
||||||
import {
|
|
||||||
EXIT_ISSUE_FETCH_FAILED,
|
|
||||||
EXIT_WORKFLOW_FAILED,
|
|
||||||
EXIT_GIT_OPERATION_FAILED,
|
|
||||||
EXIT_PR_CREATION_FAILED,
|
|
||||||
} from '../exitCodes.js';
|
|
||||||
import type { ProviderType } from '../providers/index.js';
|
|
||||||
|
|
||||||
const log = createLogger('pipeline');
|
|
||||||
|
|
||||||
export interface PipelineExecutionOptions {
|
|
||||||
/** GitHub issue number */
|
|
||||||
issueNumber?: number;
|
|
||||||
/** Task content (alternative to issue) */
|
|
||||||
task?: string;
|
|
||||||
/** Workflow name or path to workflow file */
|
|
||||||
workflow: string;
|
|
||||||
/** Branch name (auto-generated if omitted) */
|
|
||||||
branch?: string;
|
|
||||||
/** Whether to create a PR after successful execution */
|
|
||||||
autoPr: boolean;
|
|
||||||
/** Repository in owner/repo format */
|
|
||||||
repo?: string;
|
|
||||||
/** Skip branch creation, commit, and push (workflow-only execution) */
|
|
||||||
skipGit?: boolean;
|
|
||||||
/** Working directory */
|
|
||||||
cwd: string;
|
|
||||||
provider?: ProviderType;
|
|
||||||
model?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Expand template variables in a string.
|
|
||||||
* Supported: {title}, {issue}, {issue_body}, {report}
|
|
||||||
*/
|
|
||||||
function expandTemplate(template: string, vars: Record<string, string>): string {
|
|
||||||
return template.replace(/\{(\w+)\}/g, (match, key: string) => vars[key] ?? match);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Generate a branch name for pipeline execution */
|
|
||||||
function generatePipelineBranchName(pipelineConfig: PipelineConfig | undefined, issueNumber?: number): string {
|
|
||||||
const prefix = pipelineConfig?.defaultBranchPrefix ?? 'takt/';
|
|
||||||
const timestamp = Math.floor(Date.now() / 1000);
|
|
||||||
if (issueNumber) {
|
|
||||||
return `${prefix}issue-${issueNumber}-${timestamp}`;
|
|
||||||
}
|
|
||||||
return `${prefix}pipeline-${timestamp}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Create and checkout a new branch */
|
|
||||||
function createBranch(cwd: string, branch: string): void {
|
|
||||||
execFileSync('git', ['checkout', '-b', branch], {
|
|
||||||
cwd,
|
|
||||||
stdio: 'pipe',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Build commit message from template or defaults */
|
|
||||||
function buildCommitMessage(
|
|
||||||
pipelineConfig: PipelineConfig | undefined,
|
|
||||||
issue: GitHubIssue | undefined,
|
|
||||||
taskText: string | undefined,
|
|
||||||
): string {
|
|
||||||
const template = pipelineConfig?.commitMessageTemplate;
|
|
||||||
if (template && issue) {
|
|
||||||
return expandTemplate(template, {
|
|
||||||
title: issue.title,
|
|
||||||
issue: String(issue.number),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
// Default commit message
|
|
||||||
return issue
|
|
||||||
? `feat: ${issue.title} (#${issue.number})`
|
|
||||||
: `takt: ${taskText ?? 'pipeline task'}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Build PR body from template or defaults */
|
|
||||||
function buildPipelinePrBody(
|
|
||||||
pipelineConfig: PipelineConfig | undefined,
|
|
||||||
issue: GitHubIssue | undefined,
|
|
||||||
report: string,
|
|
||||||
): string {
|
|
||||||
const template = pipelineConfig?.prBodyTemplate;
|
|
||||||
if (template && issue) {
|
|
||||||
return expandTemplate(template, {
|
|
||||||
title: issue.title,
|
|
||||||
issue: String(issue.number),
|
|
||||||
issue_body: issue.body || issue.title,
|
|
||||||
report,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return buildPrBody(issue, report);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Execute the full pipeline.
|
|
||||||
*
|
|
||||||
* Returns a process exit code (0 on success, 2-5 on specific failures).
|
|
||||||
*/
|
|
||||||
export async function executePipeline(options: PipelineExecutionOptions): Promise<number> {
|
|
||||||
const { cwd, workflow, autoPr, skipGit } = options;
|
|
||||||
const globalConfig = loadGlobalConfig();
|
|
||||||
const pipelineConfig = globalConfig.pipeline;
|
|
||||||
let issue: GitHubIssue | undefined;
|
|
||||||
let task: string;
|
|
||||||
|
|
||||||
// --- Step 1: Resolve task content ---
|
|
||||||
if (options.issueNumber) {
|
|
||||||
info(`Fetching issue #${options.issueNumber}...`);
|
|
||||||
try {
|
|
||||||
const ghStatus = checkGhCli();
|
|
||||||
if (!ghStatus.available) {
|
|
||||||
error(ghStatus.error ?? 'gh CLI is not available');
|
|
||||||
return EXIT_ISSUE_FETCH_FAILED;
|
|
||||||
}
|
|
||||||
issue = fetchIssue(options.issueNumber);
|
|
||||||
task = formatIssueAsTask(issue);
|
|
||||||
success(`Issue #${options.issueNumber} fetched: "${issue.title}"`);
|
|
||||||
} catch (err) {
|
|
||||||
error(`Failed to fetch issue #${options.issueNumber}: ${getErrorMessage(err)}`);
|
|
||||||
return EXIT_ISSUE_FETCH_FAILED;
|
|
||||||
}
|
|
||||||
} else if (options.task) {
|
|
||||||
task = options.task;
|
|
||||||
} else {
|
|
||||||
error('Either --issue or --task must be specified');
|
|
||||||
return EXIT_ISSUE_FETCH_FAILED;
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Step 2: Create branch (skip if --skip-git) ---
|
|
||||||
let branch: string | undefined;
|
|
||||||
if (!skipGit) {
|
|
||||||
branch = options.branch ?? generatePipelineBranchName(pipelineConfig, options.issueNumber);
|
|
||||||
info(`Creating branch: ${branch}`);
|
|
||||||
try {
|
|
||||||
createBranch(cwd, branch);
|
|
||||||
success(`Branch created: ${branch}`);
|
|
||||||
} catch (err) {
|
|
||||||
error(`Failed to create branch: ${getErrorMessage(err)}`);
|
|
||||||
return EXIT_GIT_OPERATION_FAILED;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Step 3: Run workflow ---
|
|
||||||
info(`Running workflow: ${workflow}`);
|
|
||||||
log.info('Pipeline workflow execution starting', { workflow, branch, skipGit, issueNumber: options.issueNumber });
|
|
||||||
|
|
||||||
const agentOverrides: TaskExecutionOptions | undefined = (options.provider || options.model)
|
|
||||||
? { provider: options.provider, model: options.model }
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const taskSuccess = await executeTask({
|
|
||||||
task,
|
|
||||||
cwd,
|
|
||||||
workflowIdentifier: workflow,
|
|
||||||
projectCwd: cwd,
|
|
||||||
agentOverrides,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!taskSuccess) {
|
|
||||||
error(`Workflow '${workflow}' failed`);
|
|
||||||
return EXIT_WORKFLOW_FAILED;
|
|
||||||
}
|
|
||||||
success(`Workflow '${workflow}' completed`);
|
|
||||||
|
|
||||||
// --- Step 4: Commit & push (skip if --skip-git) ---
|
|
||||||
if (!skipGit && branch) {
|
|
||||||
const commitMessage = buildCommitMessage(pipelineConfig, issue, options.task);
|
|
||||||
|
|
||||||
info('Committing changes...');
|
|
||||||
try {
|
|
||||||
const commitHash = stageAndCommit(cwd, commitMessage);
|
|
||||||
if (commitHash) {
|
|
||||||
success(`Changes committed: ${commitHash}`);
|
|
||||||
} else {
|
|
||||||
info('No changes to commit');
|
|
||||||
}
|
|
||||||
|
|
||||||
info(`Pushing to origin/${branch}...`);
|
|
||||||
pushBranch(cwd, branch);
|
|
||||||
success(`Pushed to origin/${branch}`);
|
|
||||||
} catch (err) {
|
|
||||||
error(`Git operation failed: ${getErrorMessage(err)}`);
|
|
||||||
return EXIT_GIT_OPERATION_FAILED;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Step 5: Create PR (if --auto-pr) ---
|
|
||||||
if (autoPr) {
|
|
||||||
if (skipGit) {
|
|
||||||
info('--auto-pr is ignored when --skip-git is specified (no push was performed)');
|
|
||||||
} else if (branch) {
|
|
||||||
info('Creating pull request...');
|
|
||||||
const prTitle = issue ? issue.title : (options.task ?? 'Pipeline task');
|
|
||||||
const report = `Workflow \`${workflow}\` completed successfully.`;
|
|
||||||
const prBody = buildPipelinePrBody(pipelineConfig, issue, report);
|
|
||||||
|
|
||||||
const prResult = createPullRequest(cwd, {
|
|
||||||
branch,
|
|
||||||
title: prTitle,
|
|
||||||
body: prBody,
|
|
||||||
repo: options.repo,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (prResult.success) {
|
|
||||||
success(`PR created: ${prResult.url}`);
|
|
||||||
} else {
|
|
||||||
error(`PR creation failed: ${prResult.error}`);
|
|
||||||
return EXIT_PR_CREATION_FAILED;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Summary ---
|
|
||||||
blankLine();
|
|
||||||
status('Issue', issue ? `#${issue.number} "${issue.title}"` : 'N/A');
|
|
||||||
status('Branch', branch ?? '(current)');
|
|
||||||
status('Workflow', workflow);
|
|
||||||
status('Result', 'Success', 'green');
|
|
||||||
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,184 +1,7 @@
|
|||||||
/**
|
/** Re-export shim — actual implementation in execution/selectAndExecute.ts */
|
||||||
* Task execution orchestration.
|
export {
|
||||||
*
|
selectAndExecuteTask,
|
||||||
* Coordinates workflow selection, worktree creation, task execution,
|
confirmAndCreateWorktree,
|
||||||
* auto-commit, and PR creation. Extracted from cli.ts to avoid
|
type SelectAndExecuteOptions,
|
||||||
* mixing CLI parsing with business logic.
|
type WorktreeConfirmationResult,
|
||||||
*/
|
} from './execution/selectAndExecute.js';
|
||||||
|
|
||||||
import { getCurrentWorkflow } from '../config/paths.js';
|
|
||||||
import { listWorkflows, isWorkflowPath } from '../config/workflowLoader.js';
|
|
||||||
import { selectOptionWithDefault, confirm } from '../prompt/index.js';
|
|
||||||
import { createSharedClone } from '../task/clone.js';
|
|
||||||
import { autoCommitAndPush } from '../task/autoCommit.js';
|
|
||||||
import { summarizeTaskName } from '../task/summarize.js';
|
|
||||||
import { DEFAULT_WORKFLOW_NAME } from '../constants.js';
|
|
||||||
import { info, error, success } from '../utils/ui.js';
|
|
||||||
import { createLogger } from '../utils/debug.js';
|
|
||||||
import { createPullRequest, buildPrBody } from '../github/pr.js';
|
|
||||||
import { executeTask } from './taskExecution.js';
|
|
||||||
import type { TaskExecutionOptions } from './taskExecution.js';
|
|
||||||
|
|
||||||
const log = createLogger('selectAndExecute');
|
|
||||||
|
|
||||||
export interface WorktreeConfirmationResult {
|
|
||||||
execCwd: string;
|
|
||||||
isWorktree: boolean;
|
|
||||||
branch?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SelectAndExecuteOptions {
|
|
||||||
autoPr?: boolean;
|
|
||||||
repo?: string;
|
|
||||||
workflow?: string;
|
|
||||||
createWorktree?: boolean | undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Select a workflow interactively.
|
|
||||||
* Returns the selected workflow name, or null if cancelled.
|
|
||||||
*/
|
|
||||||
async function selectWorkflow(cwd: string): Promise<string | null> {
|
|
||||||
const availableWorkflows = listWorkflows(cwd);
|
|
||||||
const currentWorkflow = getCurrentWorkflow(cwd);
|
|
||||||
|
|
||||||
if (availableWorkflows.length === 0) {
|
|
||||||
info(`No workflows found. Using default: ${DEFAULT_WORKFLOW_NAME}`);
|
|
||||||
return DEFAULT_WORKFLOW_NAME;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (availableWorkflows.length === 1 && availableWorkflows[0]) {
|
|
||||||
return availableWorkflows[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
const options = availableWorkflows.map((name) => ({
|
|
||||||
label: name === currentWorkflow ? `${name} (current)` : name,
|
|
||||||
value: name,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const defaultWorkflow = availableWorkflows.includes(currentWorkflow)
|
|
||||||
? currentWorkflow
|
|
||||||
: (availableWorkflows.includes(DEFAULT_WORKFLOW_NAME)
|
|
||||||
? DEFAULT_WORKFLOW_NAME
|
|
||||||
: availableWorkflows[0] || DEFAULT_WORKFLOW_NAME);
|
|
||||||
|
|
||||||
return selectOptionWithDefault('Select workflow:', options, defaultWorkflow);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Determine workflow to use.
|
|
||||||
*
|
|
||||||
* - If override looks like a path (isWorkflowPath), return it directly (validation is done at load time).
|
|
||||||
* - If override is a name, validate it exists in available workflows.
|
|
||||||
* - If no override, prompt user to select interactively.
|
|
||||||
*/
|
|
||||||
async function determineWorkflow(cwd: string, override?: string): Promise<string | null> {
|
|
||||||
if (override) {
|
|
||||||
// Path-based: skip name validation (loader handles existence check)
|
|
||||||
if (isWorkflowPath(override)) {
|
|
||||||
return override;
|
|
||||||
}
|
|
||||||
// Name-based: validate workflow name exists
|
|
||||||
const availableWorkflows = listWorkflows(cwd);
|
|
||||||
const knownWorkflows = availableWorkflows.length === 0 ? [DEFAULT_WORKFLOW_NAME] : availableWorkflows;
|
|
||||||
if (!knownWorkflows.includes(override)) {
|
|
||||||
error(`Workflow not found: ${override}`);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return override;
|
|
||||||
}
|
|
||||||
return selectWorkflow(cwd);
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function confirmAndCreateWorktree(
|
|
||||||
cwd: string,
|
|
||||||
task: string,
|
|
||||||
createWorktreeOverride?: boolean | undefined,
|
|
||||||
): Promise<WorktreeConfirmationResult> {
|
|
||||||
const useWorktree =
|
|
||||||
typeof createWorktreeOverride === 'boolean'
|
|
||||||
? createWorktreeOverride
|
|
||||||
: await confirm('Create worktree?', true);
|
|
||||||
|
|
||||||
if (!useWorktree) {
|
|
||||||
return { execCwd: cwd, isWorktree: false };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Summarize task name to English slug using AI
|
|
||||||
info('Generating branch name...');
|
|
||||||
const taskSlug = await summarizeTaskName(task, { cwd });
|
|
||||||
|
|
||||||
const result = createSharedClone(cwd, {
|
|
||||||
worktree: true,
|
|
||||||
taskSlug,
|
|
||||||
});
|
|
||||||
info(`Clone created: ${result.path} (branch: ${result.branch})`);
|
|
||||||
|
|
||||||
return { execCwd: result.path, isWorktree: true, branch: result.branch };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Execute a task with workflow selection, optional worktree, and auto-commit.
|
|
||||||
* Shared by direct task execution and interactive mode.
|
|
||||||
*/
|
|
||||||
export async function selectAndExecuteTask(
|
|
||||||
cwd: string,
|
|
||||||
task: string,
|
|
||||||
options?: SelectAndExecuteOptions,
|
|
||||||
agentOverrides?: TaskExecutionOptions,
|
|
||||||
): Promise<void> {
|
|
||||||
const workflowIdentifier = await determineWorkflow(cwd, options?.workflow);
|
|
||||||
|
|
||||||
if (workflowIdentifier === null) {
|
|
||||||
info('Cancelled');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { execCwd, isWorktree, branch } = await confirmAndCreateWorktree(
|
|
||||||
cwd,
|
|
||||||
task,
|
|
||||||
options?.createWorktree,
|
|
||||||
);
|
|
||||||
|
|
||||||
log.info('Starting task execution', { workflow: workflowIdentifier, worktree: isWorktree });
|
|
||||||
const taskSuccess = await executeTask({
|
|
||||||
task,
|
|
||||||
cwd: execCwd,
|
|
||||||
workflowIdentifier,
|
|
||||||
projectCwd: cwd,
|
|
||||||
agentOverrides,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (taskSuccess && isWorktree) {
|
|
||||||
const commitResult = autoCommitAndPush(execCwd, task, cwd);
|
|
||||||
if (commitResult.success && commitResult.commitHash) {
|
|
||||||
success(`Auto-committed & pushed: ${commitResult.commitHash}`);
|
|
||||||
} else if (!commitResult.success) {
|
|
||||||
error(`Auto-commit failed: ${commitResult.message}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// PR creation: --auto-pr → create automatically, otherwise ask
|
|
||||||
if (commitResult.success && commitResult.commitHash && branch) {
|
|
||||||
const shouldCreatePr = options?.autoPr === true || await confirm('Create pull request?', false);
|
|
||||||
if (shouldCreatePr) {
|
|
||||||
info('Creating pull request...');
|
|
||||||
const prBody = buildPrBody(undefined, `Workflow \`${workflowIdentifier}\` completed successfully.`);
|
|
||||||
const prResult = createPullRequest(execCwd, {
|
|
||||||
branch,
|
|
||||||
title: task.length > 100 ? `${task.slice(0, 97)}...` : task,
|
|
||||||
body: prBody,
|
|
||||||
repo: options?.repo,
|
|
||||||
});
|
|
||||||
if (prResult.success) {
|
|
||||||
success(`PR created: ${prResult.url}`);
|
|
||||||
} else {
|
|
||||||
error(`PR creation failed: ${prResult.error}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!taskSuccess) {
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,30 +1,2 @@
|
|||||||
/**
|
/** Re-export shim — actual implementation in execution/session.ts */
|
||||||
* Session management helpers for agent execution
|
export { withAgentSession } from './execution/session.js';
|
||||||
*/
|
|
||||||
|
|
||||||
import { loadAgentSessions, updateAgentSession } from '../config/paths.js';
|
|
||||||
import { loadGlobalConfig } from '../config/globalConfig.js';
|
|
||||||
import type { AgentResponse } from '../models/types.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Execute a function with agent session management.
|
|
||||||
* Automatically loads existing session and saves updated session ID.
|
|
||||||
*/
|
|
||||||
export async function withAgentSession(
|
|
||||||
cwd: string,
|
|
||||||
agentName: string,
|
|
||||||
fn: (sessionId?: string) => Promise<AgentResponse>,
|
|
||||||
provider?: string
|
|
||||||
): Promise<AgentResponse> {
|
|
||||||
const resolvedProvider = provider ?? loadGlobalConfig().provider ?? 'claude';
|
|
||||||
const sessions = loadAgentSessions(cwd, resolvedProvider);
|
|
||||||
const sessionId = sessions[agentName];
|
|
||||||
|
|
||||||
const result = await fn(sessionId);
|
|
||||||
|
|
||||||
if (result.sessionId) {
|
|
||||||
updateAgentSession(cwd, agentName, result.sessionId, resolvedProvider);
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,249 +1,2 @@
|
|||||||
/**
|
/** Re-export shim — actual implementation in execution/taskExecution.ts */
|
||||||
* Task execution logic
|
export { executeTask, runAllTasks, executeAndCompleteTask, resolveTaskExecution, type TaskExecutionOptions } from './execution/taskExecution.js';
|
||||||
*/
|
|
||||||
|
|
||||||
import { loadWorkflowByIdentifier, isWorkflowPath, loadGlobalConfig } from '../config/index.js';
|
|
||||||
import { TaskRunner, type TaskInfo } from '../task/index.js';
|
|
||||||
import { createSharedClone } from '../task/clone.js';
|
|
||||||
import { autoCommitAndPush } from '../task/autoCommit.js';
|
|
||||||
import { summarizeTaskName } from '../task/summarize.js';
|
|
||||||
import {
|
|
||||||
header,
|
|
||||||
info,
|
|
||||||
error,
|
|
||||||
success,
|
|
||||||
status,
|
|
||||||
blankLine,
|
|
||||||
} from '../utils/ui.js';
|
|
||||||
import { createLogger } from '../utils/debug.js';
|
|
||||||
import { getErrorMessage } from '../utils/error.js';
|
|
||||||
import { executeWorkflow } from './workflowExecution.js';
|
|
||||||
import { DEFAULT_WORKFLOW_NAME } from '../constants.js';
|
|
||||||
import type { ProviderType } from '../providers/index.js';
|
|
||||||
|
|
||||||
const log = createLogger('task');
|
|
||||||
|
|
||||||
export interface TaskExecutionOptions {
|
|
||||||
provider?: ProviderType;
|
|
||||||
model?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ExecuteTaskOptions {
|
|
||||||
/** Task content */
|
|
||||||
task: string;
|
|
||||||
/** Working directory (may be a clone path) */
|
|
||||||
cwd: string;
|
|
||||||
/** Workflow name or path (auto-detected by isWorkflowPath) */
|
|
||||||
workflowIdentifier: string;
|
|
||||||
/** Project root (where .takt/ lives) */
|
|
||||||
projectCwd: string;
|
|
||||||
/** Agent provider/model overrides */
|
|
||||||
agentOverrides?: TaskExecutionOptions;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Execute a single task with workflow.
|
|
||||||
*/
|
|
||||||
export async function executeTask(options: ExecuteTaskOptions): Promise<boolean> {
|
|
||||||
const { task, cwd, workflowIdentifier, projectCwd, agentOverrides } = options;
|
|
||||||
const workflowConfig = loadWorkflowByIdentifier(workflowIdentifier, projectCwd);
|
|
||||||
|
|
||||||
if (!workflowConfig) {
|
|
||||||
if (isWorkflowPath(workflowIdentifier)) {
|
|
||||||
error(`Workflow file not found: ${workflowIdentifier}`);
|
|
||||||
} else {
|
|
||||||
error(`Workflow "${workflowIdentifier}" not found.`);
|
|
||||||
info('Available workflows are in ~/.takt/workflows/ or .takt/workflows/');
|
|
||||||
info('Use "takt switch" to select a workflow.');
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
log.debug('Running workflow', {
|
|
||||||
name: workflowConfig.name,
|
|
||||||
steps: workflowConfig.steps.map((s: { name: string }) => s.name),
|
|
||||||
});
|
|
||||||
|
|
||||||
const globalConfig = loadGlobalConfig();
|
|
||||||
const result = await executeWorkflow(workflowConfig, task, cwd, {
|
|
||||||
projectCwd,
|
|
||||||
language: globalConfig.language,
|
|
||||||
provider: agentOverrides?.provider,
|
|
||||||
model: agentOverrides?.model,
|
|
||||||
});
|
|
||||||
return result.success;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Execute a task: resolve clone → run workflow → auto-commit+push → remove clone → record completion.
|
|
||||||
*
|
|
||||||
* Shared by runAllTasks() and watchTasks() to avoid duplicated
|
|
||||||
* resolve → execute → autoCommit → complete logic.
|
|
||||||
*
|
|
||||||
* @returns true if the task succeeded
|
|
||||||
*/
|
|
||||||
export async function executeAndCompleteTask(
|
|
||||||
task: TaskInfo,
|
|
||||||
taskRunner: TaskRunner,
|
|
||||||
cwd: string,
|
|
||||||
workflowName: string,
|
|
||||||
options?: TaskExecutionOptions,
|
|
||||||
): Promise<boolean> {
|
|
||||||
const startedAt = new Date().toISOString();
|
|
||||||
const executionLog: string[] = [];
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { execCwd, execWorkflow, isWorktree } = await resolveTaskExecution(task, cwd, workflowName);
|
|
||||||
|
|
||||||
// cwd is always the project root; pass it as projectCwd so reports/sessions go there
|
|
||||||
const taskSuccess = await executeTask({
|
|
||||||
task: task.content,
|
|
||||||
cwd: execCwd,
|
|
||||||
workflowIdentifier: execWorkflow,
|
|
||||||
projectCwd: cwd,
|
|
||||||
agentOverrides: options,
|
|
||||||
});
|
|
||||||
const completedAt = new Date().toISOString();
|
|
||||||
|
|
||||||
if (taskSuccess && isWorktree) {
|
|
||||||
const commitResult = autoCommitAndPush(execCwd, task.name, cwd);
|
|
||||||
if (commitResult.success && commitResult.commitHash) {
|
|
||||||
info(`Auto-committed & pushed: ${commitResult.commitHash}`);
|
|
||||||
} else if (!commitResult.success) {
|
|
||||||
error(`Auto-commit failed: ${commitResult.message}`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const taskResult = {
|
|
||||||
task,
|
|
||||||
success: taskSuccess,
|
|
||||||
response: taskSuccess ? 'Task completed successfully' : 'Task failed',
|
|
||||||
executionLog,
|
|
||||||
startedAt,
|
|
||||||
completedAt,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (taskSuccess) {
|
|
||||||
taskRunner.completeTask(taskResult);
|
|
||||||
success(`Task "${task.name}" completed`);
|
|
||||||
} else {
|
|
||||||
taskRunner.failTask(taskResult);
|
|
||||||
error(`Task "${task.name}" failed`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return taskSuccess;
|
|
||||||
} catch (err) {
|
|
||||||
const completedAt = new Date().toISOString();
|
|
||||||
|
|
||||||
taskRunner.failTask({
|
|
||||||
task,
|
|
||||||
success: false,
|
|
||||||
response: getErrorMessage(err),
|
|
||||||
executionLog,
|
|
||||||
startedAt,
|
|
||||||
completedAt,
|
|
||||||
});
|
|
||||||
|
|
||||||
error(`Task "${task.name}" error: ${getErrorMessage(err)}`);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Run all pending tasks from .takt/tasks/
|
|
||||||
*
|
|
||||||
* タスクを動的に取得する。各タスク実行前に次のタスクを取得するため、
|
|
||||||
* 実行中にタスクファイルが追加・削除されても反映される。
|
|
||||||
*/
|
|
||||||
export async function runAllTasks(
|
|
||||||
cwd: string,
|
|
||||||
workflowName: string = DEFAULT_WORKFLOW_NAME,
|
|
||||||
options?: TaskExecutionOptions,
|
|
||||||
): Promise<void> {
|
|
||||||
const taskRunner = new TaskRunner(cwd);
|
|
||||||
|
|
||||||
// 最初のタスクを取得
|
|
||||||
let task = taskRunner.getNextTask();
|
|
||||||
|
|
||||||
if (!task) {
|
|
||||||
info('No pending tasks in .takt/tasks/');
|
|
||||||
info('Create task files as .takt/tasks/*.yaml or use takt add');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
header('Running tasks');
|
|
||||||
|
|
||||||
let successCount = 0;
|
|
||||||
let failCount = 0;
|
|
||||||
|
|
||||||
while (task) {
|
|
||||||
blankLine();
|
|
||||||
info(`=== Task: ${task.name} ===`);
|
|
||||||
blankLine();
|
|
||||||
|
|
||||||
const taskSuccess = await executeAndCompleteTask(task, taskRunner, cwd, workflowName, options);
|
|
||||||
|
|
||||||
if (taskSuccess) {
|
|
||||||
successCount++;
|
|
||||||
} else {
|
|
||||||
failCount++;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 次のタスクを動的に取得(新しく追加されたタスクも含む)
|
|
||||||
task = taskRunner.getNextTask();
|
|
||||||
}
|
|
||||||
|
|
||||||
const totalCount = successCount + failCount;
|
|
||||||
blankLine();
|
|
||||||
header('Tasks Summary');
|
|
||||||
status('Total', String(totalCount));
|
|
||||||
status('Success', String(successCount), successCount === totalCount ? 'green' : undefined);
|
|
||||||
if (failCount > 0) {
|
|
||||||
status('Failed', String(failCount), 'red');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve execution directory and workflow from task data.
|
|
||||||
* If the task has worktree settings, create a shared clone and use it as cwd.
|
|
||||||
* Task name is summarized to English by AI for use in branch/clone names.
|
|
||||||
*/
|
|
||||||
export async function resolveTaskExecution(
|
|
||||||
task: TaskInfo,
|
|
||||||
defaultCwd: string,
|
|
||||||
defaultWorkflow: string
|
|
||||||
): Promise<{ execCwd: string; execWorkflow: string; isWorktree: boolean; branch?: string }> {
|
|
||||||
const data = task.data;
|
|
||||||
|
|
||||||
// No structured data: use defaults
|
|
||||||
if (!data) {
|
|
||||||
return { execCwd: defaultCwd, execWorkflow: defaultWorkflow, isWorktree: false };
|
|
||||||
}
|
|
||||||
|
|
||||||
let execCwd = defaultCwd;
|
|
||||||
let isWorktree = false;
|
|
||||||
let branch: string | undefined;
|
|
||||||
|
|
||||||
// Handle worktree (now creates a shared clone)
|
|
||||||
if (data.worktree) {
|
|
||||||
// Summarize task content to English slug using AI
|
|
||||||
info('Generating branch name...');
|
|
||||||
const taskSlug = await summarizeTaskName(task.content, { cwd: defaultCwd });
|
|
||||||
|
|
||||||
const result = createSharedClone(defaultCwd, {
|
|
||||||
worktree: data.worktree,
|
|
||||||
branch: data.branch,
|
|
||||||
taskSlug,
|
|
||||||
issueNumber: data.issue,
|
|
||||||
});
|
|
||||||
execCwd = result.path;
|
|
||||||
branch = result.branch;
|
|
||||||
isWorktree = true;
|
|
||||||
info(`Clone created: ${result.path} (branch: ${result.branch})`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Handle workflow override
|
|
||||||
const execWorkflow = data.workflow || defaultWorkflow;
|
|
||||||
|
|
||||||
return { execCwd, execWorkflow, isWorktree, branch };
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,83 +1,2 @@
|
|||||||
/**
|
/** Re-export shim — actual implementation in management/watchTasks.ts */
|
||||||
* /watch command implementation
|
export { watchTasks } from './management/watchTasks.js';
|
||||||
*
|
|
||||||
* Watches .takt/tasks/ for new task files and executes them automatically.
|
|
||||||
* Stays resident until Ctrl+C (SIGINT).
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { TaskRunner, type TaskInfo } from '../task/index.js';
|
|
||||||
import { TaskWatcher } from '../task/watcher.js';
|
|
||||||
import { getCurrentWorkflow } from '../config/paths.js';
|
|
||||||
import {
|
|
||||||
header,
|
|
||||||
info,
|
|
||||||
success,
|
|
||||||
status,
|
|
||||||
blankLine,
|
|
||||||
} from '../utils/ui.js';
|
|
||||||
import { executeAndCompleteTask } from './taskExecution.js';
|
|
||||||
import { DEFAULT_WORKFLOW_NAME } from '../constants.js';
|
|
||||||
import type { TaskExecutionOptions } from './taskExecution.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Watch for tasks and execute them as they appear.
|
|
||||||
* Runs until Ctrl+C.
|
|
||||||
*/
|
|
||||||
export async function watchTasks(cwd: string, options?: TaskExecutionOptions): Promise<void> {
|
|
||||||
const workflowName = getCurrentWorkflow(cwd) || DEFAULT_WORKFLOW_NAME;
|
|
||||||
const taskRunner = new TaskRunner(cwd);
|
|
||||||
const watcher = new TaskWatcher(cwd);
|
|
||||||
|
|
||||||
let taskCount = 0;
|
|
||||||
let successCount = 0;
|
|
||||||
let failCount = 0;
|
|
||||||
|
|
||||||
header('TAKT Watch Mode');
|
|
||||||
info(`Workflow: ${workflowName}`);
|
|
||||||
info(`Watching: ${taskRunner.getTasksDir()}`);
|
|
||||||
info('Waiting for tasks... (Ctrl+C to stop)');
|
|
||||||
blankLine();
|
|
||||||
|
|
||||||
// Graceful shutdown on SIGINT
|
|
||||||
const onSigInt = () => {
|
|
||||||
blankLine();
|
|
||||||
info('Stopping watch...');
|
|
||||||
watcher.stop();
|
|
||||||
};
|
|
||||||
process.on('SIGINT', onSigInt);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await watcher.watch(async (task: TaskInfo) => {
|
|
||||||
taskCount++;
|
|
||||||
blankLine();
|
|
||||||
info(`=== Task ${taskCount}: ${task.name} ===`);
|
|
||||||
blankLine();
|
|
||||||
|
|
||||||
const taskSuccess = await executeAndCompleteTask(task, taskRunner, cwd, workflowName, options);
|
|
||||||
|
|
||||||
if (taskSuccess) {
|
|
||||||
successCount++;
|
|
||||||
} else {
|
|
||||||
failCount++;
|
|
||||||
}
|
|
||||||
|
|
||||||
blankLine();
|
|
||||||
info('Waiting for tasks... (Ctrl+C to stop)');
|
|
||||||
});
|
|
||||||
} finally {
|
|
||||||
process.removeListener('SIGINT', onSigInt);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Summary on exit
|
|
||||||
if (taskCount > 0) {
|
|
||||||
blankLine();
|
|
||||||
header('Watch Summary');
|
|
||||||
status('Total', String(taskCount));
|
|
||||||
status('Success', String(successCount), successCount === taskCount ? 'green' : undefined);
|
|
||||||
if (failCount > 0) {
|
|
||||||
status('Failed', String(failCount), 'red');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
success('Watch stopped.');
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,63 +1,2 @@
|
|||||||
/**
|
/** Re-export shim — actual implementation in management/workflow.ts */
|
||||||
* Workflow switching command
|
export { switchWorkflow } from './management/workflow.js';
|
||||||
*/
|
|
||||||
|
|
||||||
import { listWorkflows, loadWorkflow } from '../config/index.js';
|
|
||||||
import { getCurrentWorkflow, setCurrentWorkflow } from '../config/paths.js';
|
|
||||||
import { info, success, error } from '../utils/ui.js';
|
|
||||||
import { selectOption } from '../prompt/index.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get all available workflow options
|
|
||||||
*/
|
|
||||||
function getAllWorkflowOptions(cwd: string): { label: string; value: string }[] {
|
|
||||||
const current = getCurrentWorkflow(cwd);
|
|
||||||
const workflows = listWorkflows(cwd);
|
|
||||||
|
|
||||||
const options: { label: string; value: string }[] = [];
|
|
||||||
|
|
||||||
// Add all workflows
|
|
||||||
for (const name of workflows) {
|
|
||||||
const isCurrent = name === current;
|
|
||||||
const label = isCurrent ? `${name} (current)` : name;
|
|
||||||
options.push({ label, value: name });
|
|
||||||
}
|
|
||||||
|
|
||||||
return options;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Switch to a different workflow
|
|
||||||
* @returns true if switch was successful
|
|
||||||
*/
|
|
||||||
export async function switchWorkflow(cwd: string, workflowName?: string): Promise<boolean> {
|
|
||||||
// No workflow specified - show selection prompt
|
|
||||||
if (!workflowName) {
|
|
||||||
const current = getCurrentWorkflow(cwd);
|
|
||||||
info(`Current workflow: ${current}`);
|
|
||||||
|
|
||||||
const options = getAllWorkflowOptions(cwd);
|
|
||||||
const selected = await selectOption('Select workflow:', options);
|
|
||||||
|
|
||||||
if (!selected) {
|
|
||||||
info('Cancelled');
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
workflowName = selected;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if workflow exists
|
|
||||||
const config = loadWorkflow(workflowName, cwd);
|
|
||||||
|
|
||||||
if (!config) {
|
|
||||||
error(`Workflow "${workflowName}" not found`);
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Save to project config
|
|
||||||
setCurrentWorkflow(cwd, workflowName);
|
|
||||||
success(`Switched to workflow: ${workflowName}`);
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,359 +1,2 @@
|
|||||||
/**
|
/** Re-export shim — actual implementation in execution/workflowExecution.ts */
|
||||||
* Workflow execution logic
|
export { executeWorkflow, type WorkflowExecutionResult, type WorkflowExecutionOptions } from './execution/workflowExecution.js';
|
||||||
*/
|
|
||||||
|
|
||||||
import { readFileSync } from 'node:fs';
|
|
||||||
import { WorkflowEngine } from '../workflow/engine.js';
|
|
||||||
import type { WorkflowConfig, Language } from '../models/types.js';
|
|
||||||
import type { IterationLimitRequest } from '../workflow/types.js';
|
|
||||||
import type { ProviderType } from '../providers/index.js';
|
|
||||||
import {
|
|
||||||
loadAgentSessions,
|
|
||||||
updateAgentSession,
|
|
||||||
loadWorktreeSessions,
|
|
||||||
updateWorktreeSession,
|
|
||||||
} from '../config/paths.js';
|
|
||||||
import { loadGlobalConfig } from '../config/globalConfig.js';
|
|
||||||
import { isQuietMode } from '../context.js';
|
|
||||||
import {
|
|
||||||
header,
|
|
||||||
info,
|
|
||||||
warn,
|
|
||||||
error,
|
|
||||||
success,
|
|
||||||
status,
|
|
||||||
blankLine,
|
|
||||||
StreamDisplay,
|
|
||||||
} from '../utils/ui.js';
|
|
||||||
import {
|
|
||||||
generateSessionId,
|
|
||||||
createSessionLog,
|
|
||||||
finalizeSessionLog,
|
|
||||||
updateLatestPointer,
|
|
||||||
initNdjsonLog,
|
|
||||||
appendNdjsonLine,
|
|
||||||
type NdjsonStepStart,
|
|
||||||
type NdjsonStepComplete,
|
|
||||||
type NdjsonWorkflowComplete,
|
|
||||||
type NdjsonWorkflowAbort,
|
|
||||||
} from '../utils/session.js';
|
|
||||||
import { createLogger } from '../utils/debug.js';
|
|
||||||
import { notifySuccess, notifyError } from '../utils/notification.js';
|
|
||||||
import { selectOption, promptInput } from '../prompt/index.js';
|
|
||||||
import { EXIT_SIGINT } from '../exitCodes.js';
|
|
||||||
|
|
||||||
const log = createLogger('workflow');
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Format elapsed time in human-readable format
|
|
||||||
*/
|
|
||||||
function formatElapsedTime(startTime: string, endTime: string): string {
|
|
||||||
const start = new Date(startTime).getTime();
|
|
||||||
const end = new Date(endTime).getTime();
|
|
||||||
const elapsedMs = end - start;
|
|
||||||
const elapsedSec = elapsedMs / 1000;
|
|
||||||
|
|
||||||
if (elapsedSec < 60) {
|
|
||||||
return `${elapsedSec.toFixed(1)}s`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const minutes = Math.floor(elapsedSec / 60);
|
|
||||||
const seconds = Math.floor(elapsedSec % 60);
|
|
||||||
return `${minutes}m ${seconds}s`;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Result of workflow execution */
|
|
||||||
export interface WorkflowExecutionResult {
|
|
||||||
success: boolean;
|
|
||||||
reason?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Options for workflow execution */
|
|
||||||
export interface WorkflowExecutionOptions {
|
|
||||||
/** Header prefix for display */
|
|
||||||
headerPrefix?: string;
|
|
||||||
/** Project root directory (where .takt/ lives). */
|
|
||||||
projectCwd: string;
|
|
||||||
/** Language for instruction metadata */
|
|
||||||
language?: Language;
|
|
||||||
provider?: ProviderType;
|
|
||||||
model?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Execute a workflow and handle all events
|
|
||||||
*/
|
|
||||||
export async function executeWorkflow(
|
|
||||||
workflowConfig: WorkflowConfig,
|
|
||||||
task: string,
|
|
||||||
cwd: string,
|
|
||||||
options: WorkflowExecutionOptions
|
|
||||||
): Promise<WorkflowExecutionResult> {
|
|
||||||
const {
|
|
||||||
headerPrefix = 'Running Workflow:',
|
|
||||||
} = options;
|
|
||||||
|
|
||||||
// projectCwd is where .takt/ lives (project root, not the clone)
|
|
||||||
const projectCwd = options.projectCwd;
|
|
||||||
|
|
||||||
// Always continue from previous sessions (use /clear to reset)
|
|
||||||
log.debug('Continuing session (use /clear to reset)');
|
|
||||||
|
|
||||||
header(`${headerPrefix} ${workflowConfig.name}`);
|
|
||||||
|
|
||||||
const workflowSessionId = generateSessionId();
|
|
||||||
let sessionLog = createSessionLog(task, projectCwd, workflowConfig.name);
|
|
||||||
|
|
||||||
// Initialize NDJSON log file + pointer at workflow start
|
|
||||||
const ndjsonLogPath = initNdjsonLog(workflowSessionId, task, workflowConfig.name, projectCwd);
|
|
||||||
updateLatestPointer(sessionLog, workflowSessionId, projectCwd, { copyToPrevious: true });
|
|
||||||
|
|
||||||
// Track current display for streaming
|
|
||||||
const displayRef: { current: StreamDisplay | null } = { current: null };
|
|
||||||
|
|
||||||
// Create stream handler that delegates to UI display
|
|
||||||
const streamHandler = (
|
|
||||||
event: Parameters<ReturnType<StreamDisplay['createHandler']>>[0]
|
|
||||||
): void => {
|
|
||||||
if (!displayRef.current) return;
|
|
||||||
if (event.type === 'result') return;
|
|
||||||
displayRef.current.createHandler()(event);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Load saved agent sessions for continuity (from project root or clone-specific storage)
|
|
||||||
const isWorktree = cwd !== projectCwd;
|
|
||||||
const currentProvider = loadGlobalConfig().provider ?? 'claude';
|
|
||||||
const savedSessions = isWorktree
|
|
||||||
? loadWorktreeSessions(projectCwd, cwd, currentProvider)
|
|
||||||
: loadAgentSessions(projectCwd, currentProvider);
|
|
||||||
|
|
||||||
// Session update handler - persist session IDs when they change
|
|
||||||
// Clone sessions are stored separately per clone path
|
|
||||||
const sessionUpdateHandler = isWorktree
|
|
||||||
? (agentName: string, agentSessionId: string): void => {
|
|
||||||
updateWorktreeSession(projectCwd, cwd, agentName, agentSessionId, currentProvider);
|
|
||||||
}
|
|
||||||
: (agentName: string, agentSessionId: string): void => {
|
|
||||||
updateAgentSession(projectCwd, agentName, agentSessionId, currentProvider);
|
|
||||||
};
|
|
||||||
|
|
||||||
const iterationLimitHandler = async (
|
|
||||||
request: IterationLimitRequest
|
|
||||||
): Promise<number | null> => {
|
|
||||||
if (displayRef.current) {
|
|
||||||
displayRef.current.flush();
|
|
||||||
displayRef.current = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
blankLine();
|
|
||||||
warn(
|
|
||||||
`最大イテレーションに到達しました (${request.currentIteration}/${request.maxIterations})`
|
|
||||||
);
|
|
||||||
info(`現在のステップ: ${request.currentStep}`);
|
|
||||||
|
|
||||||
const action = await selectOption('続行しますか?', [
|
|
||||||
{
|
|
||||||
label: '続行する(追加イテレーション数を入力)',
|
|
||||||
value: 'continue',
|
|
||||||
description: '入力した回数だけ上限を増やします',
|
|
||||||
},
|
|
||||||
{ label: '終了する', value: 'stop' },
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (action !== 'continue') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
while (true) {
|
|
||||||
const input = await promptInput('追加するイテレーション数を入力してください(1以上)');
|
|
||||||
if (!input) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const additionalIterations = Number.parseInt(input, 10);
|
|
||||||
if (Number.isInteger(additionalIterations) && additionalIterations > 0) {
|
|
||||||
workflowConfig.maxIterations += additionalIterations;
|
|
||||||
return additionalIterations;
|
|
||||||
}
|
|
||||||
|
|
||||||
warn('1以上の整数を入力してください。');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const engine = new WorkflowEngine(workflowConfig, cwd, task, {
|
|
||||||
onStream: streamHandler,
|
|
||||||
initialSessions: savedSessions,
|
|
||||||
onSessionUpdate: sessionUpdateHandler,
|
|
||||||
onIterationLimit: iterationLimitHandler,
|
|
||||||
projectCwd,
|
|
||||||
language: options.language,
|
|
||||||
provider: options.provider,
|
|
||||||
model: options.model,
|
|
||||||
});
|
|
||||||
|
|
||||||
let abortReason: string | undefined;
|
|
||||||
|
|
||||||
engine.on('step:start', (step, iteration, instruction) => {
|
|
||||||
log.debug('Step starting', { step: step.name, agent: step.agentDisplayName, iteration });
|
|
||||||
info(`[${iteration}/${workflowConfig.maxIterations}] ${step.name} (${step.agentDisplayName})`);
|
|
||||||
|
|
||||||
// Log prompt content for debugging
|
|
||||||
if (instruction) {
|
|
||||||
log.debug('Step instruction', instruction);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Use quiet mode from CLI (already resolved CLI flag + config in preAction)
|
|
||||||
displayRef.current = new StreamDisplay(step.agentDisplayName, isQuietMode());
|
|
||||||
|
|
||||||
// Write step_start record to NDJSON log
|
|
||||||
const record: NdjsonStepStart = {
|
|
||||||
type: 'step_start',
|
|
||||||
step: step.name,
|
|
||||||
agent: step.agentDisplayName,
|
|
||||||
iteration,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
...(instruction ? { instruction } : {}),
|
|
||||||
};
|
|
||||||
appendNdjsonLine(ndjsonLogPath, record);
|
|
||||||
});
|
|
||||||
|
|
||||||
engine.on('step:complete', (step, response, instruction) => {
|
|
||||||
log.debug('Step completed', {
|
|
||||||
step: step.name,
|
|
||||||
status: response.status,
|
|
||||||
matchedRuleIndex: response.matchedRuleIndex,
|
|
||||||
matchedRuleMethod: response.matchedRuleMethod,
|
|
||||||
contentLength: response.content.length,
|
|
||||||
sessionId: response.sessionId,
|
|
||||||
error: response.error,
|
|
||||||
});
|
|
||||||
if (displayRef.current) {
|
|
||||||
displayRef.current.flush();
|
|
||||||
displayRef.current = null;
|
|
||||||
}
|
|
||||||
blankLine();
|
|
||||||
|
|
||||||
if (response.matchedRuleIndex != null && step.rules) {
|
|
||||||
const rule = step.rules[response.matchedRuleIndex];
|
|
||||||
if (rule) {
|
|
||||||
const methodLabel = response.matchedRuleMethod ? ` (${response.matchedRuleMethod})` : '';
|
|
||||||
status('Status', `${rule.condition}${methodLabel}`);
|
|
||||||
} else {
|
|
||||||
status('Status', response.status);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
status('Status', response.status);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (response.error) {
|
|
||||||
error(`Error: ${response.error}`);
|
|
||||||
}
|
|
||||||
if (response.sessionId) {
|
|
||||||
status('Session', response.sessionId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write step_complete record to NDJSON log
|
|
||||||
const record: NdjsonStepComplete = {
|
|
||||||
type: 'step_complete',
|
|
||||||
step: step.name,
|
|
||||||
agent: response.agent,
|
|
||||||
status: response.status,
|
|
||||||
content: response.content,
|
|
||||||
instruction,
|
|
||||||
...(response.matchedRuleIndex != null ? { matchedRuleIndex: response.matchedRuleIndex } : {}),
|
|
||||||
...(response.matchedRuleMethod ? { matchedRuleMethod: response.matchedRuleMethod } : {}),
|
|
||||||
...(response.error ? { error: response.error } : {}),
|
|
||||||
timestamp: response.timestamp.toISOString(),
|
|
||||||
};
|
|
||||||
appendNdjsonLine(ndjsonLogPath, record);
|
|
||||||
|
|
||||||
// Update in-memory log for pointer metadata (immutable)
|
|
||||||
sessionLog = { ...sessionLog, iterations: sessionLog.iterations + 1 };
|
|
||||||
updateLatestPointer(sessionLog, workflowSessionId, projectCwd);
|
|
||||||
});
|
|
||||||
|
|
||||||
engine.on('step:report', (_step, filePath, fileName) => {
|
|
||||||
const content = readFileSync(filePath, 'utf-8');
|
|
||||||
console.log(`\n📄 Report: ${fileName}\n`);
|
|
||||||
console.log(content);
|
|
||||||
});
|
|
||||||
|
|
||||||
engine.on('workflow:complete', (state) => {
|
|
||||||
log.info('Workflow completed successfully', { iterations: state.iteration });
|
|
||||||
sessionLog = finalizeSessionLog(sessionLog, 'completed');
|
|
||||||
|
|
||||||
// Write workflow_complete record to NDJSON log
|
|
||||||
const record: NdjsonWorkflowComplete = {
|
|
||||||
type: 'workflow_complete',
|
|
||||||
iterations: state.iteration,
|
|
||||||
endTime: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
appendNdjsonLine(ndjsonLogPath, record);
|
|
||||||
updateLatestPointer(sessionLog, workflowSessionId, projectCwd);
|
|
||||||
|
|
||||||
const elapsed = sessionLog.endTime
|
|
||||||
? formatElapsedTime(sessionLog.startTime, sessionLog.endTime)
|
|
||||||
: '';
|
|
||||||
const elapsedDisplay = elapsed ? `, ${elapsed}` : '';
|
|
||||||
|
|
||||||
success(`Workflow completed (${state.iteration} iterations${elapsedDisplay})`);
|
|
||||||
info(`Session log: ${ndjsonLogPath}`);
|
|
||||||
notifySuccess('TAKT', `ワークフロー完了 (${state.iteration} iterations)`);
|
|
||||||
});
|
|
||||||
|
|
||||||
engine.on('workflow:abort', (state, reason) => {
|
|
||||||
log.error('Workflow aborted', { reason, iterations: state.iteration });
|
|
||||||
if (displayRef.current) {
|
|
||||||
displayRef.current.flush();
|
|
||||||
displayRef.current = null;
|
|
||||||
}
|
|
||||||
abortReason = reason;
|
|
||||||
sessionLog = finalizeSessionLog(sessionLog, 'aborted');
|
|
||||||
|
|
||||||
// Write workflow_abort record to NDJSON log
|
|
||||||
const record: NdjsonWorkflowAbort = {
|
|
||||||
type: 'workflow_abort',
|
|
||||||
iterations: state.iteration,
|
|
||||||
reason,
|
|
||||||
endTime: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
appendNdjsonLine(ndjsonLogPath, record);
|
|
||||||
updateLatestPointer(sessionLog, workflowSessionId, projectCwd);
|
|
||||||
|
|
||||||
const elapsed = sessionLog.endTime
|
|
||||||
? formatElapsedTime(sessionLog.startTime, sessionLog.endTime)
|
|
||||||
: '';
|
|
||||||
const elapsedDisplay = elapsed ? ` (${elapsed})` : '';
|
|
||||||
|
|
||||||
error(`Workflow aborted after ${state.iteration} iterations${elapsedDisplay}: ${reason}`);
|
|
||||||
info(`Session log: ${ndjsonLogPath}`);
|
|
||||||
notifyError('TAKT', `中断: ${reason}`);
|
|
||||||
});
|
|
||||||
|
|
||||||
// SIGINT handler: 1st Ctrl+C = graceful abort, 2nd = force exit
|
|
||||||
let sigintCount = 0;
|
|
||||||
const onSigInt = () => {
|
|
||||||
sigintCount++;
|
|
||||||
if (sigintCount === 1) {
|
|
||||||
blankLine();
|
|
||||||
warn('Ctrl+C: ワークフローを中断しています...');
|
|
||||||
engine.abort();
|
|
||||||
} else {
|
|
||||||
blankLine();
|
|
||||||
error('Ctrl+C: 強制終了します');
|
|
||||||
process.exit(EXIT_SIGINT);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
process.on('SIGINT', onSigInt);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const finalState = await engine.run();
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: finalState.status === 'completed',
|
|
||||||
reason: abortReason,
|
|
||||||
};
|
|
||||||
} finally {
|
|
||||||
process.removeListener('SIGINT', onSigInt);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,110 +1,10 @@
|
|||||||
/**
|
/**
|
||||||
* Agent configuration loader
|
* Re-export shim — actual implementation in loaders/agentLoader.ts
|
||||||
*
|
|
||||||
* Loads agents with user → builtin fallback:
|
|
||||||
* 1. User agents: ~/.takt/agents/*.md
|
|
||||||
* 2. Builtin agents: resources/global/{lang}/agents/*.md
|
|
||||||
*/
|
*/
|
||||||
|
export {
|
||||||
import { readFileSync, existsSync, readdirSync } from 'node:fs';
|
loadAgentsFromDir,
|
||||||
import { join, basename } from 'node:path';
|
loadCustomAgents,
|
||||||
import type { CustomAgentConfig } from '../models/types.js';
|
listCustomAgents,
|
||||||
import {
|
loadAgentPrompt,
|
||||||
getGlobalAgentsDir,
|
loadAgentPromptFromPath,
|
||||||
getGlobalWorkflowsDir,
|
} from './loaders/agentLoader.js';
|
||||||
getBuiltinAgentsDir,
|
|
||||||
getBuiltinWorkflowsDir,
|
|
||||||
isPathSafe,
|
|
||||||
} from './paths.js';
|
|
||||||
import { getLanguage } from './globalConfig.js';
|
|
||||||
|
|
||||||
/** Get all allowed base directories for agent prompt files */
|
|
||||||
function getAllowedAgentBases(): string[] {
|
|
||||||
const lang = getLanguage();
|
|
||||||
return [
|
|
||||||
getGlobalAgentsDir(),
|
|
||||||
getGlobalWorkflowsDir(),
|
|
||||||
getBuiltinAgentsDir(lang),
|
|
||||||
getBuiltinWorkflowsDir(lang),
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Load agents from markdown files in a directory */
|
|
||||||
export function loadAgentsFromDir(dirPath: string): CustomAgentConfig[] {
|
|
||||||
if (!existsSync(dirPath)) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
const agents: CustomAgentConfig[] = [];
|
|
||||||
for (const file of readdirSync(dirPath)) {
|
|
||||||
if (file.endsWith('.md')) {
|
|
||||||
const name = basename(file, '.md');
|
|
||||||
const promptFile = join(dirPath, file);
|
|
||||||
agents.push({
|
|
||||||
name,
|
|
||||||
promptFile,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return agents;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Load all custom agents from global directory (~/.takt/agents/) */
|
|
||||||
export function loadCustomAgents(): Map<string, CustomAgentConfig> {
|
|
||||||
const agents = new Map<string, CustomAgentConfig>();
|
|
||||||
|
|
||||||
// Global agents from markdown files (~/.takt/agents/*.md)
|
|
||||||
for (const agent of loadAgentsFromDir(getGlobalAgentsDir())) {
|
|
||||||
agents.set(agent.name, agent);
|
|
||||||
}
|
|
||||||
|
|
||||||
return agents;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** List available custom agents */
|
|
||||||
export function listCustomAgents(): string[] {
|
|
||||||
return Array.from(loadCustomAgents().keys()).sort();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load agent prompt content.
|
|
||||||
* Agents can be loaded from:
|
|
||||||
* - ~/.takt/agents/*.md (global agents)
|
|
||||||
* - ~/.takt/workflows/{workflow}/*.md (workflow-specific agents)
|
|
||||||
*/
|
|
||||||
export function loadAgentPrompt(agent: CustomAgentConfig): string {
|
|
||||||
if (agent.prompt) {
|
|
||||||
return agent.prompt;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (agent.promptFile) {
|
|
||||||
const isValid = getAllowedAgentBases().some((base) => isPathSafe(base, agent.promptFile!));
|
|
||||||
if (!isValid) {
|
|
||||||
throw new Error(`Agent prompt file path is not allowed: ${agent.promptFile}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!existsSync(agent.promptFile)) {
|
|
||||||
throw new Error(`Agent prompt file not found: ${agent.promptFile}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return readFileSync(agent.promptFile, 'utf-8');
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error(`Agent ${agent.name} has no prompt defined`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load agent prompt from a resolved path.
|
|
||||||
* Used by workflow engine when agentPath is already resolved.
|
|
||||||
*/
|
|
||||||
export function loadAgentPromptFromPath(agentPath: string): string {
|
|
||||||
const isValid = getAllowedAgentBases().some((base) => isPathSafe(base, agentPath));
|
|
||||||
if (!isValid) {
|
|
||||||
throw new Error(`Agent prompt file path is not allowed: ${agentPath}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!existsSync(agentPath)) {
|
|
||||||
throw new Error(`Agent prompt file not found: ${agentPath}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return readFileSync(agentPath, 'utf-8');
|
|
||||||
}
|
|
||||||
|
|||||||
243
src/config/global/globalConfig.ts
Normal file
243
src/config/global/globalConfig.ts
Normal file
@ -0,0 +1,243 @@
|
|||||||
|
/**
|
||||||
|
* Global configuration loader
|
||||||
|
*
|
||||||
|
* Manages ~/.takt/config.yaml and project-level debug settings.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { readFileSync, existsSync, writeFileSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
|
||||||
|
import { GlobalConfigSchema } from '../../models/schemas.js';
|
||||||
|
import type { GlobalConfig, DebugConfig, Language } from '../../models/types.js';
|
||||||
|
import { getGlobalConfigPath, getProjectConfigPath } from '../paths.js';
|
||||||
|
import { DEFAULT_LANGUAGE } from '../../constants.js';
|
||||||
|
|
||||||
|
/** Create default global configuration (fresh instance each call) */
|
||||||
|
function createDefaultGlobalConfig(): GlobalConfig {
|
||||||
|
return {
|
||||||
|
language: DEFAULT_LANGUAGE,
|
||||||
|
trustedDirectories: [],
|
||||||
|
defaultWorkflow: 'default',
|
||||||
|
logLevel: 'info',
|
||||||
|
provider: 'claude',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Module-level cache for global configuration */
|
||||||
|
let cachedConfig: GlobalConfig | null = null;
|
||||||
|
|
||||||
|
/** Invalidate the cached global configuration (call after mutation) */
|
||||||
|
export function invalidateGlobalConfigCache(): void {
|
||||||
|
cachedConfig = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Load global configuration */
|
||||||
|
export function loadGlobalConfig(): GlobalConfig {
|
||||||
|
if (cachedConfig !== null) {
|
||||||
|
return cachedConfig;
|
||||||
|
}
|
||||||
|
const configPath = getGlobalConfigPath();
|
||||||
|
if (!existsSync(configPath)) {
|
||||||
|
const defaultConfig = createDefaultGlobalConfig();
|
||||||
|
cachedConfig = defaultConfig;
|
||||||
|
return defaultConfig;
|
||||||
|
}
|
||||||
|
const content = readFileSync(configPath, 'utf-8');
|
||||||
|
const raw = parseYaml(content);
|
||||||
|
const parsed = GlobalConfigSchema.parse(raw);
|
||||||
|
const config: GlobalConfig = {
|
||||||
|
language: parsed.language,
|
||||||
|
trustedDirectories: parsed.trusted_directories,
|
||||||
|
defaultWorkflow: parsed.default_workflow,
|
||||||
|
logLevel: parsed.log_level,
|
||||||
|
provider: parsed.provider,
|
||||||
|
model: parsed.model,
|
||||||
|
debug: parsed.debug ? {
|
||||||
|
enabled: parsed.debug.enabled,
|
||||||
|
logFile: parsed.debug.log_file,
|
||||||
|
} : undefined,
|
||||||
|
worktreeDir: parsed.worktree_dir,
|
||||||
|
disabledBuiltins: parsed.disabled_builtins,
|
||||||
|
anthropicApiKey: parsed.anthropic_api_key,
|
||||||
|
openaiApiKey: parsed.openai_api_key,
|
||||||
|
pipeline: parsed.pipeline ? {
|
||||||
|
defaultBranchPrefix: parsed.pipeline.default_branch_prefix,
|
||||||
|
commitMessageTemplate: parsed.pipeline.commit_message_template,
|
||||||
|
prBodyTemplate: parsed.pipeline.pr_body_template,
|
||||||
|
} : undefined,
|
||||||
|
minimalOutput: parsed.minimal_output,
|
||||||
|
};
|
||||||
|
cachedConfig = config;
|
||||||
|
return config;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Save global configuration */
|
||||||
|
export function saveGlobalConfig(config: GlobalConfig): void {
|
||||||
|
const configPath = getGlobalConfigPath();
|
||||||
|
const raw: Record<string, unknown> = {
|
||||||
|
language: config.language,
|
||||||
|
trusted_directories: config.trustedDirectories,
|
||||||
|
default_workflow: config.defaultWorkflow,
|
||||||
|
log_level: config.logLevel,
|
||||||
|
provider: config.provider,
|
||||||
|
};
|
||||||
|
if (config.model) {
|
||||||
|
raw.model = config.model;
|
||||||
|
}
|
||||||
|
if (config.debug) {
|
||||||
|
raw.debug = {
|
||||||
|
enabled: config.debug.enabled,
|
||||||
|
log_file: config.debug.logFile,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (config.worktreeDir) {
|
||||||
|
raw.worktree_dir = config.worktreeDir;
|
||||||
|
}
|
||||||
|
if (config.disabledBuiltins && config.disabledBuiltins.length > 0) {
|
||||||
|
raw.disabled_builtins = config.disabledBuiltins;
|
||||||
|
}
|
||||||
|
if (config.anthropicApiKey) {
|
||||||
|
raw.anthropic_api_key = config.anthropicApiKey;
|
||||||
|
}
|
||||||
|
if (config.openaiApiKey) {
|
||||||
|
raw.openai_api_key = config.openaiApiKey;
|
||||||
|
}
|
||||||
|
if (config.pipeline) {
|
||||||
|
const pipelineRaw: Record<string, unknown> = {};
|
||||||
|
if (config.pipeline.defaultBranchPrefix) pipelineRaw.default_branch_prefix = config.pipeline.defaultBranchPrefix;
|
||||||
|
if (config.pipeline.commitMessageTemplate) pipelineRaw.commit_message_template = config.pipeline.commitMessageTemplate;
|
||||||
|
if (config.pipeline.prBodyTemplate) pipelineRaw.pr_body_template = config.pipeline.prBodyTemplate;
|
||||||
|
if (Object.keys(pipelineRaw).length > 0) {
|
||||||
|
raw.pipeline = pipelineRaw;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (config.minimalOutput !== undefined) {
|
||||||
|
raw.minimal_output = config.minimalOutput;
|
||||||
|
}
|
||||||
|
writeFileSync(configPath, stringifyYaml(raw), 'utf-8');
|
||||||
|
invalidateGlobalConfigCache();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get list of disabled builtin names */
|
||||||
|
export function getDisabledBuiltins(): string[] {
|
||||||
|
try {
|
||||||
|
const config = loadGlobalConfig();
|
||||||
|
return config.disabledBuiltins ?? [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get current language setting */
|
||||||
|
export function getLanguage(): Language {
|
||||||
|
try {
|
||||||
|
const config = loadGlobalConfig();
|
||||||
|
return config.language;
|
||||||
|
} catch {
|
||||||
|
return DEFAULT_LANGUAGE;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Set language setting */
|
||||||
|
export function setLanguage(language: Language): void {
|
||||||
|
const config = loadGlobalConfig();
|
||||||
|
config.language = language;
|
||||||
|
saveGlobalConfig(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Set provider setting */
|
||||||
|
export function setProvider(provider: 'claude' | 'codex'): void {
|
||||||
|
const config = loadGlobalConfig();
|
||||||
|
config.provider = provider;
|
||||||
|
saveGlobalConfig(config);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Add a trusted directory */
|
||||||
|
export function addTrustedDirectory(dir: string): void {
|
||||||
|
const config = loadGlobalConfig();
|
||||||
|
const resolvedDir = join(dir);
|
||||||
|
if (!config.trustedDirectories.includes(resolvedDir)) {
|
||||||
|
config.trustedDirectories.push(resolvedDir);
|
||||||
|
saveGlobalConfig(config);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Check if a directory is trusted */
|
||||||
|
export function isDirectoryTrusted(dir: string): boolean {
|
||||||
|
const config = loadGlobalConfig();
|
||||||
|
const resolvedDir = join(dir);
|
||||||
|
return config.trustedDirectories.some(
|
||||||
|
(trusted) => resolvedDir === trusted || resolvedDir.startsWith(trusted + '/')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the Anthropic API key.
|
||||||
|
* Priority: TAKT_ANTHROPIC_API_KEY env var > config.yaml > undefined (CLI auth fallback)
|
||||||
|
*/
|
||||||
|
export function resolveAnthropicApiKey(): string | undefined {
|
||||||
|
const envKey = process.env['TAKT_ANTHROPIC_API_KEY'];
|
||||||
|
if (envKey) return envKey;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const config = loadGlobalConfig();
|
||||||
|
return config.anthropicApiKey;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve the OpenAI API key.
|
||||||
|
* Priority: TAKT_OPENAI_API_KEY env var > config.yaml > undefined (CLI auth fallback)
|
||||||
|
*/
|
||||||
|
export function resolveOpenaiApiKey(): string | undefined {
|
||||||
|
const envKey = process.env['TAKT_OPENAI_API_KEY'];
|
||||||
|
if (envKey) return envKey;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const config = loadGlobalConfig();
|
||||||
|
return config.openaiApiKey;
|
||||||
|
} catch {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Load project-level debug configuration (from .takt/config.yaml) */
|
||||||
|
export function loadProjectDebugConfig(projectDir: string): DebugConfig | undefined {
|
||||||
|
const configPath = getProjectConfigPath(projectDir);
|
||||||
|
if (!existsSync(configPath)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const content = readFileSync(configPath, 'utf-8');
|
||||||
|
const raw = parseYaml(content);
|
||||||
|
if (raw && typeof raw === 'object' && 'debug' in raw) {
|
||||||
|
const debug = raw.debug;
|
||||||
|
if (debug && typeof debug === 'object') {
|
||||||
|
return {
|
||||||
|
enabled: Boolean(debug.enabled),
|
||||||
|
logFile: typeof debug.log_file === 'string' ? debug.log_file : undefined,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore parse errors
|
||||||
|
}
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get effective debug config (project overrides global) */
|
||||||
|
export function getEffectiveDebugConfig(projectDir?: string): DebugConfig | undefined {
|
||||||
|
const globalConfig = loadGlobalConfig();
|
||||||
|
let debugConfig = globalConfig.debug;
|
||||||
|
|
||||||
|
if (projectDir) {
|
||||||
|
const projectDebugConfig = loadProjectDebugConfig(projectDir);
|
||||||
|
if (projectDebugConfig) {
|
||||||
|
debugConfig = projectDebugConfig;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return debugConfig;
|
||||||
|
}
|
||||||
28
src/config/global/index.ts
Normal file
28
src/config/global/index.ts
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
/**
|
||||||
|
* Global configuration - barrel exports
|
||||||
|
*/
|
||||||
|
|
||||||
|
export {
|
||||||
|
invalidateGlobalConfigCache,
|
||||||
|
loadGlobalConfig,
|
||||||
|
saveGlobalConfig,
|
||||||
|
getDisabledBuiltins,
|
||||||
|
getLanguage,
|
||||||
|
setLanguage,
|
||||||
|
setProvider,
|
||||||
|
addTrustedDirectory,
|
||||||
|
isDirectoryTrusted,
|
||||||
|
resolveAnthropicApiKey,
|
||||||
|
resolveOpenaiApiKey,
|
||||||
|
loadProjectDebugConfig,
|
||||||
|
getEffectiveDebugConfig,
|
||||||
|
} from './globalConfig.js';
|
||||||
|
|
||||||
|
export {
|
||||||
|
needsLanguageSetup,
|
||||||
|
promptLanguageSelection,
|
||||||
|
promptProviderSelection,
|
||||||
|
initGlobalDirs,
|
||||||
|
initProjectDirs,
|
||||||
|
type InitGlobalDirsOptions,
|
||||||
|
} from './initialization.js';
|
||||||
133
src/config/global/initialization.ts
Normal file
133
src/config/global/initialization.ts
Normal file
@ -0,0 +1,133 @@
|
|||||||
|
/**
|
||||||
|
* Initialization module for first-time setup
|
||||||
|
*
|
||||||
|
* Handles language selection and initial config.yaml creation.
|
||||||
|
* Builtin agents/workflows are loaded via fallback from resources/
|
||||||
|
* and no longer copied to ~/.takt/ on setup.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import type { Language } from '../../models/types.js';
|
||||||
|
import { DEFAULT_LANGUAGE } from '../../constants.js';
|
||||||
|
import { selectOptionWithDefault } from '../../prompt/index.js';
|
||||||
|
import {
|
||||||
|
getGlobalConfigDir,
|
||||||
|
getGlobalConfigPath,
|
||||||
|
getProjectConfigDir,
|
||||||
|
ensureDir,
|
||||||
|
} from '../paths.js';
|
||||||
|
import { copyProjectResourcesToDir, getLanguageResourcesDir } from '../../resources/index.js';
|
||||||
|
import { setLanguage, setProvider } from '../globalConfig.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if initial setup is needed.
|
||||||
|
* Returns true if config.yaml doesn't exist yet.
|
||||||
|
*/
|
||||||
|
export function needsLanguageSetup(): boolean {
|
||||||
|
return !existsSync(getGlobalConfigPath());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prompt user to select language for resources.
|
||||||
|
* Returns 'en' for English (default), 'ja' for Japanese.
|
||||||
|
* Exits process if cancelled (initial setup is required).
|
||||||
|
*/
|
||||||
|
export async function promptLanguageSelection(): Promise<Language> {
|
||||||
|
const options: { label: string; value: Language }[] = [
|
||||||
|
{ label: 'English', value: 'en' },
|
||||||
|
{ label: '日本語 (Japanese)', value: 'ja' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = await selectOptionWithDefault(
|
||||||
|
'Select language for default agents and workflows / デフォルトのエージェントとワークフローの言語を選択してください:',
|
||||||
|
options,
|
||||||
|
DEFAULT_LANGUAGE
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result === null) {
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prompt user to select provider for resources.
|
||||||
|
* Exits process if cancelled (initial setup is required).
|
||||||
|
*/
|
||||||
|
export async function promptProviderSelection(): Promise<'claude' | 'codex'> {
|
||||||
|
const options: { label: string; value: 'claude' | 'codex' }[] = [
|
||||||
|
{ label: 'Claude Code', value: 'claude' },
|
||||||
|
{ label: 'Codex', value: 'codex' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const result = await selectOptionWithDefault(
|
||||||
|
'Select provider (Claude Code or Codex) / プロバイダーを選択してください:',
|
||||||
|
options,
|
||||||
|
'claude'
|
||||||
|
);
|
||||||
|
|
||||||
|
if (result === null) {
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Options for global directory initialization */
|
||||||
|
export interface InitGlobalDirsOptions {
|
||||||
|
/** Skip interactive prompts (CI/non-TTY environments) */
|
||||||
|
nonInteractive?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize global takt directory structure with language selection.
|
||||||
|
* On first run, creates config.yaml from language template.
|
||||||
|
* Agents/workflows are NOT copied — they are loaded via builtin fallback.
|
||||||
|
*
|
||||||
|
* In non-interactive mode (pipeline mode or no TTY), skips prompts
|
||||||
|
* and uses default values so takt works in pipeline/CI environments without config.yaml.
|
||||||
|
*/
|
||||||
|
export async function initGlobalDirs(options?: InitGlobalDirsOptions): Promise<void> {
|
||||||
|
ensureDir(getGlobalConfigDir());
|
||||||
|
|
||||||
|
if (needsLanguageSetup()) {
|
||||||
|
const isInteractive = !options?.nonInteractive && process.stdin.isTTY === true;
|
||||||
|
|
||||||
|
if (!isInteractive) {
|
||||||
|
// Pipeline / non-interactive: skip prompts, use defaults via loadGlobalConfig() fallback
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const lang = await promptLanguageSelection();
|
||||||
|
const provider = await promptProviderSelection();
|
||||||
|
|
||||||
|
// Copy only config.yaml from language resources
|
||||||
|
copyLanguageConfigYaml(lang);
|
||||||
|
|
||||||
|
setLanguage(lang);
|
||||||
|
setProvider(provider);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Copy config.yaml from language resources to ~/.takt/ (if not already present) */
|
||||||
|
function copyLanguageConfigYaml(lang: Language): void {
|
||||||
|
const langDir = getLanguageResourcesDir(lang);
|
||||||
|
const srcPath = join(langDir, 'config.yaml');
|
||||||
|
const destPath = getGlobalConfigPath();
|
||||||
|
if (existsSync(srcPath) && !existsSync(destPath)) {
|
||||||
|
writeFileSync(destPath, readFileSync(srcPath));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize project-level .takt directory.
|
||||||
|
* Creates .takt/ and copies project resources (e.g., .gitignore).
|
||||||
|
* Only copies files that don't exist.
|
||||||
|
*/
|
||||||
|
export function initProjectDirs(projectDir: string): void {
|
||||||
|
const configDir = getProjectConfigDir(projectDir);
|
||||||
|
ensureDir(configDir);
|
||||||
|
copyProjectResourcesToDir(configDir);
|
||||||
|
}
|
||||||
@ -1,243 +1,18 @@
|
|||||||
/**
|
/**
|
||||||
* Global configuration loader
|
* Re-export shim — actual implementation in global/globalConfig.ts
|
||||||
*
|
|
||||||
* Manages ~/.takt/config.yaml and project-level debug settings.
|
|
||||||
*/
|
*/
|
||||||
|
export {
|
||||||
import { readFileSync, existsSync, writeFileSync } from 'node:fs';
|
invalidateGlobalConfigCache,
|
||||||
import { join } from 'node:path';
|
loadGlobalConfig,
|
||||||
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
|
saveGlobalConfig,
|
||||||
import { GlobalConfigSchema } from '../models/schemas.js';
|
getDisabledBuiltins,
|
||||||
import type { GlobalConfig, DebugConfig, Language } from '../models/types.js';
|
getLanguage,
|
||||||
import { getGlobalConfigPath, getProjectConfigPath } from './paths.js';
|
setLanguage,
|
||||||
import { DEFAULT_LANGUAGE } from '../constants.js';
|
setProvider,
|
||||||
|
addTrustedDirectory,
|
||||||
/** Create default global configuration (fresh instance each call) */
|
isDirectoryTrusted,
|
||||||
function createDefaultGlobalConfig(): GlobalConfig {
|
resolveAnthropicApiKey,
|
||||||
return {
|
resolveOpenaiApiKey,
|
||||||
language: DEFAULT_LANGUAGE,
|
loadProjectDebugConfig,
|
||||||
trustedDirectories: [],
|
getEffectiveDebugConfig,
|
||||||
defaultWorkflow: 'default',
|
} from './global/globalConfig.js';
|
||||||
logLevel: 'info',
|
|
||||||
provider: 'claude',
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Module-level cache for global configuration */
|
|
||||||
let cachedConfig: GlobalConfig | null = null;
|
|
||||||
|
|
||||||
/** Invalidate the cached global configuration (call after mutation) */
|
|
||||||
export function invalidateGlobalConfigCache(): void {
|
|
||||||
cachedConfig = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Load global configuration */
|
|
||||||
export function loadGlobalConfig(): GlobalConfig {
|
|
||||||
if (cachedConfig !== null) {
|
|
||||||
return cachedConfig;
|
|
||||||
}
|
|
||||||
const configPath = getGlobalConfigPath();
|
|
||||||
if (!existsSync(configPath)) {
|
|
||||||
const defaultConfig = createDefaultGlobalConfig();
|
|
||||||
cachedConfig = defaultConfig;
|
|
||||||
return defaultConfig;
|
|
||||||
}
|
|
||||||
const content = readFileSync(configPath, 'utf-8');
|
|
||||||
const raw = parseYaml(content);
|
|
||||||
const parsed = GlobalConfigSchema.parse(raw);
|
|
||||||
const config: GlobalConfig = {
|
|
||||||
language: parsed.language,
|
|
||||||
trustedDirectories: parsed.trusted_directories,
|
|
||||||
defaultWorkflow: parsed.default_workflow,
|
|
||||||
logLevel: parsed.log_level,
|
|
||||||
provider: parsed.provider,
|
|
||||||
model: parsed.model,
|
|
||||||
debug: parsed.debug ? {
|
|
||||||
enabled: parsed.debug.enabled,
|
|
||||||
logFile: parsed.debug.log_file,
|
|
||||||
} : undefined,
|
|
||||||
worktreeDir: parsed.worktree_dir,
|
|
||||||
disabledBuiltins: parsed.disabled_builtins,
|
|
||||||
anthropicApiKey: parsed.anthropic_api_key,
|
|
||||||
openaiApiKey: parsed.openai_api_key,
|
|
||||||
pipeline: parsed.pipeline ? {
|
|
||||||
defaultBranchPrefix: parsed.pipeline.default_branch_prefix,
|
|
||||||
commitMessageTemplate: parsed.pipeline.commit_message_template,
|
|
||||||
prBodyTemplate: parsed.pipeline.pr_body_template,
|
|
||||||
} : undefined,
|
|
||||||
minimalOutput: parsed.minimal_output,
|
|
||||||
};
|
|
||||||
cachedConfig = config;
|
|
||||||
return config;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Save global configuration */
|
|
||||||
export function saveGlobalConfig(config: GlobalConfig): void {
|
|
||||||
const configPath = getGlobalConfigPath();
|
|
||||||
const raw: Record<string, unknown> = {
|
|
||||||
language: config.language,
|
|
||||||
trusted_directories: config.trustedDirectories,
|
|
||||||
default_workflow: config.defaultWorkflow,
|
|
||||||
log_level: config.logLevel,
|
|
||||||
provider: config.provider,
|
|
||||||
};
|
|
||||||
if (config.model) {
|
|
||||||
raw.model = config.model;
|
|
||||||
}
|
|
||||||
if (config.debug) {
|
|
||||||
raw.debug = {
|
|
||||||
enabled: config.debug.enabled,
|
|
||||||
log_file: config.debug.logFile,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (config.worktreeDir) {
|
|
||||||
raw.worktree_dir = config.worktreeDir;
|
|
||||||
}
|
|
||||||
if (config.disabledBuiltins && config.disabledBuiltins.length > 0) {
|
|
||||||
raw.disabled_builtins = config.disabledBuiltins;
|
|
||||||
}
|
|
||||||
if (config.anthropicApiKey) {
|
|
||||||
raw.anthropic_api_key = config.anthropicApiKey;
|
|
||||||
}
|
|
||||||
if (config.openaiApiKey) {
|
|
||||||
raw.openai_api_key = config.openaiApiKey;
|
|
||||||
}
|
|
||||||
if (config.pipeline) {
|
|
||||||
const pipelineRaw: Record<string, unknown> = {};
|
|
||||||
if (config.pipeline.defaultBranchPrefix) pipelineRaw.default_branch_prefix = config.pipeline.defaultBranchPrefix;
|
|
||||||
if (config.pipeline.commitMessageTemplate) pipelineRaw.commit_message_template = config.pipeline.commitMessageTemplate;
|
|
||||||
if (config.pipeline.prBodyTemplate) pipelineRaw.pr_body_template = config.pipeline.prBodyTemplate;
|
|
||||||
if (Object.keys(pipelineRaw).length > 0) {
|
|
||||||
raw.pipeline = pipelineRaw;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (config.minimalOutput !== undefined) {
|
|
||||||
raw.minimal_output = config.minimalOutput;
|
|
||||||
}
|
|
||||||
writeFileSync(configPath, stringifyYaml(raw), 'utf-8');
|
|
||||||
invalidateGlobalConfigCache();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Get list of disabled builtin names */
|
|
||||||
export function getDisabledBuiltins(): string[] {
|
|
||||||
try {
|
|
||||||
const config = loadGlobalConfig();
|
|
||||||
return config.disabledBuiltins ?? [];
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Get current language setting */
|
|
||||||
export function getLanguage(): Language {
|
|
||||||
try {
|
|
||||||
const config = loadGlobalConfig();
|
|
||||||
return config.language;
|
|
||||||
} catch {
|
|
||||||
return DEFAULT_LANGUAGE;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Set language setting */
|
|
||||||
export function setLanguage(language: Language): void {
|
|
||||||
const config = loadGlobalConfig();
|
|
||||||
config.language = language;
|
|
||||||
saveGlobalConfig(config);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Set provider setting */
|
|
||||||
export function setProvider(provider: 'claude' | 'codex'): void {
|
|
||||||
const config = loadGlobalConfig();
|
|
||||||
config.provider = provider;
|
|
||||||
saveGlobalConfig(config);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Add a trusted directory */
|
|
||||||
export function addTrustedDirectory(dir: string): void {
|
|
||||||
const config = loadGlobalConfig();
|
|
||||||
const resolvedDir = join(dir);
|
|
||||||
if (!config.trustedDirectories.includes(resolvedDir)) {
|
|
||||||
config.trustedDirectories.push(resolvedDir);
|
|
||||||
saveGlobalConfig(config);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Check if a directory is trusted */
|
|
||||||
export function isDirectoryTrusted(dir: string): boolean {
|
|
||||||
const config = loadGlobalConfig();
|
|
||||||
const resolvedDir = join(dir);
|
|
||||||
return config.trustedDirectories.some(
|
|
||||||
(trusted) => resolvedDir === trusted || resolvedDir.startsWith(trusted + '/')
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve the Anthropic API key.
|
|
||||||
* Priority: TAKT_ANTHROPIC_API_KEY env var > config.yaml > undefined (CLI auth fallback)
|
|
||||||
*/
|
|
||||||
export function resolveAnthropicApiKey(): string | undefined {
|
|
||||||
const envKey = process.env['TAKT_ANTHROPIC_API_KEY'];
|
|
||||||
if (envKey) return envKey;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const config = loadGlobalConfig();
|
|
||||||
return config.anthropicApiKey;
|
|
||||||
} catch {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve the OpenAI API key.
|
|
||||||
* Priority: TAKT_OPENAI_API_KEY env var > config.yaml > undefined (CLI auth fallback)
|
|
||||||
*/
|
|
||||||
export function resolveOpenaiApiKey(): string | undefined {
|
|
||||||
const envKey = process.env['TAKT_OPENAI_API_KEY'];
|
|
||||||
if (envKey) return envKey;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const config = loadGlobalConfig();
|
|
||||||
return config.openaiApiKey;
|
|
||||||
} catch {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Load project-level debug configuration (from .takt/config.yaml) */
|
|
||||||
export function loadProjectDebugConfig(projectDir: string): DebugConfig | undefined {
|
|
||||||
const configPath = getProjectConfigPath(projectDir);
|
|
||||||
if (!existsSync(configPath)) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const content = readFileSync(configPath, 'utf-8');
|
|
||||||
const raw = parseYaml(content);
|
|
||||||
if (raw && typeof raw === 'object' && 'debug' in raw) {
|
|
||||||
const debug = raw.debug;
|
|
||||||
if (debug && typeof debug === 'object') {
|
|
||||||
return {
|
|
||||||
enabled: Boolean(debug.enabled),
|
|
||||||
logFile: typeof debug.log_file === 'string' ? debug.log_file : undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Ignore parse errors
|
|
||||||
}
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Get effective debug config (project overrides global) */
|
|
||||||
export function getEffectiveDebugConfig(projectDir?: string): DebugConfig | undefined {
|
|
||||||
const globalConfig = loadGlobalConfig();
|
|
||||||
let debugConfig = globalConfig.debug;
|
|
||||||
|
|
||||||
if (projectDir) {
|
|
||||||
const projectDebugConfig = loadProjectDebugConfig(projectDir);
|
|
||||||
if (projectDebugConfig) {
|
|
||||||
debugConfig = projectDebugConfig;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return debugConfig;
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,133 +1,11 @@
|
|||||||
/**
|
/**
|
||||||
* Initialization module for first-time setup
|
* Re-export shim — actual implementation in global/initialization.ts
|
||||||
*
|
|
||||||
* Handles language selection and initial config.yaml creation.
|
|
||||||
* Builtin agents/workflows are loaded via fallback from resources/
|
|
||||||
* and no longer copied to ~/.takt/ on setup.
|
|
||||||
*/
|
*/
|
||||||
|
export {
|
||||||
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
|
needsLanguageSetup,
|
||||||
import { join } from 'node:path';
|
promptLanguageSelection,
|
||||||
import type { Language } from '../models/types.js';
|
promptProviderSelection,
|
||||||
import { DEFAULT_LANGUAGE } from '../constants.js';
|
initGlobalDirs,
|
||||||
import { selectOptionWithDefault } from '../prompt/index.js';
|
initProjectDirs,
|
||||||
import {
|
type InitGlobalDirsOptions,
|
||||||
getGlobalConfigDir,
|
} from './global/initialization.js';
|
||||||
getGlobalConfigPath,
|
|
||||||
getProjectConfigDir,
|
|
||||||
ensureDir,
|
|
||||||
} from './paths.js';
|
|
||||||
import { copyProjectResourcesToDir, getLanguageResourcesDir } from '../resources/index.js';
|
|
||||||
import { setLanguage, setProvider } from './globalConfig.js';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if initial setup is needed.
|
|
||||||
* Returns true if config.yaml doesn't exist yet.
|
|
||||||
*/
|
|
||||||
export function needsLanguageSetup(): boolean {
|
|
||||||
return !existsSync(getGlobalConfigPath());
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Prompt user to select language for resources.
|
|
||||||
* Returns 'en' for English (default), 'ja' for Japanese.
|
|
||||||
* Exits process if cancelled (initial setup is required).
|
|
||||||
*/
|
|
||||||
export async function promptLanguageSelection(): Promise<Language> {
|
|
||||||
const options: { label: string; value: Language }[] = [
|
|
||||||
{ label: 'English', value: 'en' },
|
|
||||||
{ label: '日本語 (Japanese)', value: 'ja' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const result = await selectOptionWithDefault(
|
|
||||||
'Select language for default agents and workflows / デフォルトのエージェントとワークフローの言語を選択してください:',
|
|
||||||
options,
|
|
||||||
DEFAULT_LANGUAGE
|
|
||||||
);
|
|
||||||
|
|
||||||
if (result === null) {
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Prompt user to select provider for resources.
|
|
||||||
* Exits process if cancelled (initial setup is required).
|
|
||||||
*/
|
|
||||||
export async function promptProviderSelection(): Promise<'claude' | 'codex'> {
|
|
||||||
const options: { label: string; value: 'claude' | 'codex' }[] = [
|
|
||||||
{ label: 'Claude Code', value: 'claude' },
|
|
||||||
{ label: 'Codex', value: 'codex' },
|
|
||||||
];
|
|
||||||
|
|
||||||
const result = await selectOptionWithDefault(
|
|
||||||
'Select provider (Claude Code or Codex) / プロバイダーを選択してください:',
|
|
||||||
options,
|
|
||||||
'claude'
|
|
||||||
);
|
|
||||||
|
|
||||||
if (result === null) {
|
|
||||||
process.exit(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Options for global directory initialization */
|
|
||||||
export interface InitGlobalDirsOptions {
|
|
||||||
/** Skip interactive prompts (CI/non-TTY environments) */
|
|
||||||
nonInteractive?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize global takt directory structure with language selection.
|
|
||||||
* On first run, creates config.yaml from language template.
|
|
||||||
* Agents/workflows are NOT copied — they are loaded via builtin fallback.
|
|
||||||
*
|
|
||||||
* In non-interactive mode (pipeline mode or no TTY), skips prompts
|
|
||||||
* and uses default values so takt works in pipeline/CI environments without config.yaml.
|
|
||||||
*/
|
|
||||||
export async function initGlobalDirs(options?: InitGlobalDirsOptions): Promise<void> {
|
|
||||||
ensureDir(getGlobalConfigDir());
|
|
||||||
|
|
||||||
if (needsLanguageSetup()) {
|
|
||||||
const isInteractive = !options?.nonInteractive && process.stdin.isTTY === true;
|
|
||||||
|
|
||||||
if (!isInteractive) {
|
|
||||||
// Pipeline / non-interactive: skip prompts, use defaults via loadGlobalConfig() fallback
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const lang = await promptLanguageSelection();
|
|
||||||
const provider = await promptProviderSelection();
|
|
||||||
|
|
||||||
// Copy only config.yaml from language resources
|
|
||||||
copyLanguageConfigYaml(lang);
|
|
||||||
|
|
||||||
setLanguage(lang);
|
|
||||||
setProvider(provider);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Copy config.yaml from language resources to ~/.takt/ (if not already present) */
|
|
||||||
function copyLanguageConfigYaml(lang: Language): void {
|
|
||||||
const langDir = getLanguageResourcesDir(lang);
|
|
||||||
const srcPath = join(langDir, 'config.yaml');
|
|
||||||
const destPath = getGlobalConfigPath();
|
|
||||||
if (existsSync(srcPath) && !existsSync(destPath)) {
|
|
||||||
writeFileSync(destPath, readFileSync(srcPath));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Initialize project-level .takt directory.
|
|
||||||
* Creates .takt/ and copies project resources (e.g., .gitignore).
|
|
||||||
* Only copies files that don't exist.
|
|
||||||
*/
|
|
||||||
export function initProjectDirs(projectDir: string): void {
|
|
||||||
const configDir = getProjectConfigDir(projectDir);
|
|
||||||
ensureDir(configDir);
|
|
||||||
copyProjectResourcesToDir(configDir);
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,5 +1,5 @@
|
|||||||
/**
|
/**
|
||||||
* Configuration loader for takt
|
* Re-export shim — actual implementation in loaders/loader.ts
|
||||||
*
|
*
|
||||||
* Re-exports from specialized loaders for backward compatibility.
|
* Re-exports from specialized loaders for backward compatibility.
|
||||||
*/
|
*/
|
||||||
@ -12,7 +12,7 @@ export {
|
|||||||
isWorkflowPath,
|
isWorkflowPath,
|
||||||
loadAllWorkflows,
|
loadAllWorkflows,
|
||||||
listWorkflows,
|
listWorkflows,
|
||||||
} from './workflowLoader.js';
|
} from './loaders/workflowLoader.js';
|
||||||
|
|
||||||
// Agent loading
|
// Agent loading
|
||||||
export {
|
export {
|
||||||
@ -21,7 +21,7 @@ export {
|
|||||||
listCustomAgents,
|
listCustomAgents,
|
||||||
loadAgentPrompt,
|
loadAgentPrompt,
|
||||||
loadAgentPromptFromPath,
|
loadAgentPromptFromPath,
|
||||||
} from './agentLoader.js';
|
} from './loaders/agentLoader.js';
|
||||||
|
|
||||||
// Global configuration
|
// Global configuration
|
||||||
export {
|
export {
|
||||||
@ -32,4 +32,4 @@ export {
|
|||||||
isDirectoryTrusted,
|
isDirectoryTrusted,
|
||||||
loadProjectDebugConfig,
|
loadProjectDebugConfig,
|
||||||
getEffectiveDebugConfig,
|
getEffectiveDebugConfig,
|
||||||
} from './globalConfig.js';
|
} from './global/globalConfig.js';
|
||||||
|
|||||||
110
src/config/loaders/agentLoader.ts
Normal file
110
src/config/loaders/agentLoader.ts
Normal file
@ -0,0 +1,110 @@
|
|||||||
|
/**
|
||||||
|
* Agent configuration loader
|
||||||
|
*
|
||||||
|
* Loads agents with user → builtin fallback:
|
||||||
|
* 1. User agents: ~/.takt/agents/*.md
|
||||||
|
* 2. Builtin agents: resources/global/{lang}/agents/*.md
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { readFileSync, existsSync, readdirSync } from 'node:fs';
|
||||||
|
import { join, basename } from 'node:path';
|
||||||
|
import type { CustomAgentConfig } from '../../models/types.js';
|
||||||
|
import {
|
||||||
|
getGlobalAgentsDir,
|
||||||
|
getGlobalWorkflowsDir,
|
||||||
|
getBuiltinAgentsDir,
|
||||||
|
getBuiltinWorkflowsDir,
|
||||||
|
isPathSafe,
|
||||||
|
} from '../paths.js';
|
||||||
|
import { getLanguage } from '../globalConfig.js';
|
||||||
|
|
||||||
|
/** Get all allowed base directories for agent prompt files */
|
||||||
|
function getAllowedAgentBases(): string[] {
|
||||||
|
const lang = getLanguage();
|
||||||
|
return [
|
||||||
|
getGlobalAgentsDir(),
|
||||||
|
getGlobalWorkflowsDir(),
|
||||||
|
getBuiltinAgentsDir(lang),
|
||||||
|
getBuiltinWorkflowsDir(lang),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Load agents from markdown files in a directory */
|
||||||
|
export function loadAgentsFromDir(dirPath: string): CustomAgentConfig[] {
|
||||||
|
if (!existsSync(dirPath)) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
const agents: CustomAgentConfig[] = [];
|
||||||
|
for (const file of readdirSync(dirPath)) {
|
||||||
|
if (file.endsWith('.md')) {
|
||||||
|
const name = basename(file, '.md');
|
||||||
|
const promptFile = join(dirPath, file);
|
||||||
|
agents.push({
|
||||||
|
name,
|
||||||
|
promptFile,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return agents;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Load all custom agents from global directory (~/.takt/agents/) */
|
||||||
|
export function loadCustomAgents(): Map<string, CustomAgentConfig> {
|
||||||
|
const agents = new Map<string, CustomAgentConfig>();
|
||||||
|
|
||||||
|
// Global agents from markdown files (~/.takt/agents/*.md)
|
||||||
|
for (const agent of loadAgentsFromDir(getGlobalAgentsDir())) {
|
||||||
|
agents.set(agent.name, agent);
|
||||||
|
}
|
||||||
|
|
||||||
|
return agents;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** List available custom agents */
|
||||||
|
export function listCustomAgents(): string[] {
|
||||||
|
return Array.from(loadCustomAgents().keys()).sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load agent prompt content.
|
||||||
|
* Agents can be loaded from:
|
||||||
|
* - ~/.takt/agents/*.md (global agents)
|
||||||
|
* - ~/.takt/workflows/{workflow}/*.md (workflow-specific agents)
|
||||||
|
*/
|
||||||
|
export function loadAgentPrompt(agent: CustomAgentConfig): string {
|
||||||
|
if (agent.prompt) {
|
||||||
|
return agent.prompt;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (agent.promptFile) {
|
||||||
|
const isValid = getAllowedAgentBases().some((base) => isPathSafe(base, agent.promptFile!));
|
||||||
|
if (!isValid) {
|
||||||
|
throw new Error(`Agent prompt file path is not allowed: ${agent.promptFile}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!existsSync(agent.promptFile)) {
|
||||||
|
throw new Error(`Agent prompt file not found: ${agent.promptFile}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return readFileSync(agent.promptFile, 'utf-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`Agent ${agent.name} has no prompt defined`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load agent prompt from a resolved path.
|
||||||
|
* Used by workflow engine when agentPath is already resolved.
|
||||||
|
*/
|
||||||
|
export function loadAgentPromptFromPath(agentPath: string): string {
|
||||||
|
const isValid = getAllowedAgentBases().some((base) => isPathSafe(base, agentPath));
|
||||||
|
if (!isValid) {
|
||||||
|
throw new Error(`Agent prompt file path is not allowed: ${agentPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!existsSync(agentPath)) {
|
||||||
|
throw new Error(`Agent prompt file not found: ${agentPath}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return readFileSync(agentPath, 'utf-8');
|
||||||
|
}
|
||||||
20
src/config/loaders/index.ts
Normal file
20
src/config/loaders/index.ts
Normal file
@ -0,0 +1,20 @@
|
|||||||
|
/**
|
||||||
|
* Configuration loaders - barrel exports
|
||||||
|
*/
|
||||||
|
|
||||||
|
export {
|
||||||
|
getBuiltinWorkflow,
|
||||||
|
loadWorkflow,
|
||||||
|
loadWorkflowByIdentifier,
|
||||||
|
isWorkflowPath,
|
||||||
|
loadAllWorkflows,
|
||||||
|
listWorkflows,
|
||||||
|
} from './workflowLoader.js';
|
||||||
|
|
||||||
|
export {
|
||||||
|
loadAgentsFromDir,
|
||||||
|
loadCustomAgents,
|
||||||
|
listCustomAgents,
|
||||||
|
loadAgentPrompt,
|
||||||
|
loadAgentPromptFromPath,
|
||||||
|
} from './agentLoader.js';
|
||||||
35
src/config/loaders/loader.ts
Normal file
35
src/config/loaders/loader.ts
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
/**
|
||||||
|
* Configuration loader for takt
|
||||||
|
*
|
||||||
|
* Re-exports from specialized loaders for backward compatibility.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Workflow loading
|
||||||
|
export {
|
||||||
|
getBuiltinWorkflow,
|
||||||
|
loadWorkflow,
|
||||||
|
loadWorkflowByIdentifier,
|
||||||
|
isWorkflowPath,
|
||||||
|
loadAllWorkflows,
|
||||||
|
listWorkflows,
|
||||||
|
} from '../workflowLoader.js';
|
||||||
|
|
||||||
|
// Agent loading
|
||||||
|
export {
|
||||||
|
loadAgentsFromDir,
|
||||||
|
loadCustomAgents,
|
||||||
|
listCustomAgents,
|
||||||
|
loadAgentPrompt,
|
||||||
|
loadAgentPromptFromPath,
|
||||||
|
} from '../agentLoader.js';
|
||||||
|
|
||||||
|
// Global configuration
|
||||||
|
export {
|
||||||
|
loadGlobalConfig,
|
||||||
|
saveGlobalConfig,
|
||||||
|
invalidateGlobalConfigCache,
|
||||||
|
addTrustedDirectory,
|
||||||
|
isDirectoryTrusted,
|
||||||
|
loadProjectDebugConfig,
|
||||||
|
getEffectiveDebugConfig,
|
||||||
|
} from '../globalConfig.js';
|
||||||
449
src/config/loaders/workflowLoader.ts
Normal file
449
src/config/loaders/workflowLoader.ts
Normal file
@ -0,0 +1,449 @@
|
|||||||
|
/**
|
||||||
|
* Workflow configuration loader
|
||||||
|
*
|
||||||
|
* Loads workflows with the following priority:
|
||||||
|
* 1. Path-based input (absolute, relative, or home-dir) → load directly from file
|
||||||
|
* 2. Project-local workflows: .takt/workflows/{name}.yaml
|
||||||
|
* 3. User workflows: ~/.takt/workflows/{name}.yaml
|
||||||
|
* 4. Builtin workflows: resources/global/{lang}/workflows/{name}.yaml
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs';
|
||||||
|
import { join, dirname, basename, resolve, isAbsolute } from 'node:path';
|
||||||
|
import { homedir } from 'node:os';
|
||||||
|
import { parse as parseYaml } from 'yaml';
|
||||||
|
import { WorkflowConfigRawSchema } from '../../models/schemas.js';
|
||||||
|
import type { WorkflowConfig, WorkflowStep, WorkflowRule, ReportConfig, ReportObjectConfig } from '../../models/types.js';
|
||||||
|
import { getGlobalWorkflowsDir, getBuiltinWorkflowsDir, getProjectConfigDir } from '../paths.js';
|
||||||
|
import { getLanguage, getDisabledBuiltins } from '../globalConfig.js';
|
||||||
|
|
||||||
|
/** Get builtin workflow by name */
|
||||||
|
export function getBuiltinWorkflow(name: string): WorkflowConfig | null {
|
||||||
|
const lang = getLanguage();
|
||||||
|
const disabled = getDisabledBuiltins();
|
||||||
|
if (disabled.includes(name)) return null;
|
||||||
|
|
||||||
|
const builtinDir = getBuiltinWorkflowsDir(lang);
|
||||||
|
const yamlPath = join(builtinDir, `${name}.yaml`);
|
||||||
|
if (existsSync(yamlPath)) {
|
||||||
|
return loadWorkflowFromFile(yamlPath);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve agent path from workflow specification.
|
||||||
|
* - Relative path (./agent.md): relative to workflow directory
|
||||||
|
* - Absolute path (/path/to/agent.md or ~/...): use as-is
|
||||||
|
*/
|
||||||
|
function resolveAgentPathForWorkflow(agentSpec: string, workflowDir: string): string {
|
||||||
|
// Relative path (starts with ./)
|
||||||
|
if (agentSpec.startsWith('./')) {
|
||||||
|
return join(workflowDir, agentSpec.slice(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Home directory expansion
|
||||||
|
if (agentSpec.startsWith('~')) {
|
||||||
|
const homedir = process.env.HOME || process.env.USERPROFILE || '';
|
||||||
|
return join(homedir, agentSpec.slice(1));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Absolute path
|
||||||
|
if (agentSpec.startsWith('/')) {
|
||||||
|
return agentSpec;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback: treat as relative to workflow directory
|
||||||
|
return join(workflowDir, agentSpec);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract display name from agent path.
|
||||||
|
* e.g., "~/.takt/agents/default/coder.md" -> "coder"
|
||||||
|
*/
|
||||||
|
function extractAgentDisplayName(agentPath: string): string {
|
||||||
|
// Get the filename without extension
|
||||||
|
const filename = basename(agentPath, '.md');
|
||||||
|
return filename;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a string value that may be a file path.
|
||||||
|
* If the value ends with .md and the file exists (resolved relative to workflowDir),
|
||||||
|
* read and return the file contents. Otherwise return the value as-is.
|
||||||
|
*/
|
||||||
|
function resolveContentPath(value: string | undefined, workflowDir: string): string | undefined {
|
||||||
|
if (value == null) return undefined;
|
||||||
|
if (value.endsWith('.md')) {
|
||||||
|
// Resolve path relative to workflow directory
|
||||||
|
let resolvedPath = value;
|
||||||
|
if (value.startsWith('./')) {
|
||||||
|
resolvedPath = join(workflowDir, value.slice(2));
|
||||||
|
} else if (value.startsWith('~')) {
|
||||||
|
const homedir = process.env.HOME || process.env.USERPROFILE || '';
|
||||||
|
resolvedPath = join(homedir, value.slice(1));
|
||||||
|
} else if (!value.startsWith('/')) {
|
||||||
|
resolvedPath = join(workflowDir, value);
|
||||||
|
}
|
||||||
|
if (existsSync(resolvedPath)) {
|
||||||
|
return readFileSync(resolvedPath, 'utf-8');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a raw report value is the object form (has 'name' property).
|
||||||
|
*/
|
||||||
|
function isReportObject(raw: unknown): raw is { name: string; order?: string; format?: string } {
|
||||||
|
return typeof raw === 'object' && raw !== null && !Array.isArray(raw) && 'name' in raw;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize the raw report field from YAML into internal format.
|
||||||
|
*
|
||||||
|
* YAML formats:
|
||||||
|
* report: "00-plan.md" → string (single file)
|
||||||
|
* report: → ReportConfig[] (multiple files)
|
||||||
|
* - Scope: 01-scope.md
|
||||||
|
* - Decisions: 02-decisions.md
|
||||||
|
* report: → ReportObjectConfig (object form)
|
||||||
|
* name: 00-plan.md
|
||||||
|
* order: ...
|
||||||
|
* format: ...
|
||||||
|
*
|
||||||
|
* Array items are parsed as single-key objects: [{Scope: "01-scope.md"}, ...]
|
||||||
|
*/
|
||||||
|
function normalizeReport(
|
||||||
|
raw: string | Record<string, string>[] | { name: string; order?: string; format?: string } | undefined,
|
||||||
|
workflowDir: string,
|
||||||
|
): string | ReportConfig[] | ReportObjectConfig | undefined {
|
||||||
|
if (raw == null) return undefined;
|
||||||
|
if (typeof raw === 'string') return raw;
|
||||||
|
if (isReportObject(raw)) {
|
||||||
|
return {
|
||||||
|
name: raw.name,
|
||||||
|
order: resolveContentPath(raw.order, workflowDir),
|
||||||
|
format: resolveContentPath(raw.format, workflowDir),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// Convert [{Scope: "01-scope.md"}, ...] to [{label: "Scope", path: "01-scope.md"}, ...]
|
||||||
|
return (raw as Record<string, string>[]).flatMap((entry) =>
|
||||||
|
Object.entries(entry).map(([label, path]) => ({ label, path })),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Regex to detect ai("...") condition expressions */
|
||||||
|
const AI_CONDITION_REGEX = /^ai\("(.+)"\)$/;
|
||||||
|
|
||||||
|
/** Regex to detect all("...")/any("...") aggregate condition expressions */
|
||||||
|
const AGGREGATE_CONDITION_REGEX = /^(all|any)\("(.+)"\)$/;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a rule's condition for ai() and all()/any() expressions.
|
||||||
|
* - `ai("text")` → sets isAiCondition and aiConditionText
|
||||||
|
* - `all("text")` / `any("text")` → sets isAggregateCondition, aggregateType, aggregateConditionText
|
||||||
|
*/
|
||||||
|
function normalizeRule(r: { condition: string; next: string; appendix?: string }): WorkflowRule {
|
||||||
|
const aiMatch = r.condition.match(AI_CONDITION_REGEX);
|
||||||
|
if (aiMatch?.[1]) {
|
||||||
|
return {
|
||||||
|
condition: r.condition,
|
||||||
|
next: r.next,
|
||||||
|
appendix: r.appendix,
|
||||||
|
isAiCondition: true,
|
||||||
|
aiConditionText: aiMatch[1],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const aggMatch = r.condition.match(AGGREGATE_CONDITION_REGEX);
|
||||||
|
if (aggMatch?.[1] && aggMatch[2]) {
|
||||||
|
return {
|
||||||
|
condition: r.condition,
|
||||||
|
next: r.next,
|
||||||
|
appendix: r.appendix,
|
||||||
|
isAggregateCondition: true,
|
||||||
|
aggregateType: aggMatch[1] as 'all' | 'any',
|
||||||
|
aggregateConditionText: aggMatch[2],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
condition: r.condition,
|
||||||
|
next: r.next,
|
||||||
|
appendix: r.appendix,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
type RawStep = any;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize a raw step into internal WorkflowStep format.
|
||||||
|
*/
|
||||||
|
function normalizeStepFromRaw(step: RawStep, workflowDir: string): WorkflowStep {
|
||||||
|
const rules: WorkflowRule[] | undefined = step.rules?.map(normalizeRule);
|
||||||
|
const agentSpec: string = step.agent ?? '';
|
||||||
|
|
||||||
|
const result: WorkflowStep = {
|
||||||
|
name: step.name,
|
||||||
|
agent: agentSpec,
|
||||||
|
agentDisplayName: step.agent_name || (agentSpec ? extractAgentDisplayName(agentSpec) : step.name),
|
||||||
|
agentPath: agentSpec ? resolveAgentPathForWorkflow(agentSpec, workflowDir) : undefined,
|
||||||
|
allowedTools: step.allowed_tools,
|
||||||
|
provider: step.provider,
|
||||||
|
model: step.model,
|
||||||
|
permissionMode: step.permission_mode,
|
||||||
|
edit: step.edit,
|
||||||
|
instructionTemplate: resolveContentPath(step.instruction_template, workflowDir) || step.instruction || '{task}',
|
||||||
|
rules,
|
||||||
|
report: normalizeReport(step.report, workflowDir),
|
||||||
|
passPreviousResponse: step.pass_previous_response ?? true,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (step.parallel && step.parallel.length > 0) {
|
||||||
|
result.parallel = step.parallel.map((sub: RawStep) => normalizeStepFromRaw(sub, workflowDir));
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert raw YAML workflow config to internal format.
|
||||||
|
* Agent paths are resolved relative to the workflow directory.
|
||||||
|
*/
|
||||||
|
function normalizeWorkflowConfig(raw: unknown, workflowDir: string): WorkflowConfig {
|
||||||
|
const parsed = WorkflowConfigRawSchema.parse(raw);
|
||||||
|
|
||||||
|
const steps: WorkflowStep[] = parsed.steps.map((step) =>
|
||||||
|
normalizeStepFromRaw(step, workflowDir),
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: parsed.name,
|
||||||
|
description: parsed.description,
|
||||||
|
steps,
|
||||||
|
initialStep: parsed.initial_step || steps[0]?.name || '',
|
||||||
|
maxIterations: parsed.max_iterations,
|
||||||
|
answerAgent: parsed.answer_agent,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load a workflow from a YAML file.
|
||||||
|
* @param filePath Path to the workflow YAML file
|
||||||
|
*/
|
||||||
|
function loadWorkflowFromFile(filePath: string): WorkflowConfig {
|
||||||
|
if (!existsSync(filePath)) {
|
||||||
|
throw new Error(`Workflow file not found: ${filePath}`);
|
||||||
|
}
|
||||||
|
const content = readFileSync(filePath, 'utf-8');
|
||||||
|
const raw = parseYaml(content);
|
||||||
|
const workflowDir = dirname(filePath);
|
||||||
|
return normalizeWorkflowConfig(raw, workflowDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolve a path that may be relative, absolute, or home-directory-relative.
|
||||||
|
* @param pathInput Path to resolve
|
||||||
|
* @param basePath Base directory for relative paths
|
||||||
|
* @returns Absolute resolved path
|
||||||
|
*/
|
||||||
|
function resolvePath(pathInput: string, basePath: string): string {
|
||||||
|
// Home directory expansion
|
||||||
|
if (pathInput.startsWith('~')) {
|
||||||
|
const home = homedir();
|
||||||
|
return resolve(home, pathInput.slice(1).replace(/^\//, ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Absolute path
|
||||||
|
if (isAbsolute(pathInput)) {
|
||||||
|
return pathInput;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Relative path
|
||||||
|
return resolve(basePath, pathInput);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load workflow from a file path.
|
||||||
|
* Called internally by loadWorkflowByIdentifier when the identifier is detected as a path.
|
||||||
|
*
|
||||||
|
* @param filePath Path to workflow file (absolute, relative, or home-dir prefixed with ~)
|
||||||
|
* @param basePath Base directory for resolving relative paths
|
||||||
|
* @returns WorkflowConfig or null if file not found
|
||||||
|
*/
|
||||||
|
function loadWorkflowFromPath(
|
||||||
|
filePath: string,
|
||||||
|
basePath: string
|
||||||
|
): WorkflowConfig | null {
|
||||||
|
const resolvedPath = resolvePath(filePath, basePath);
|
||||||
|
|
||||||
|
if (!existsSync(resolvedPath)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return loadWorkflowFromFile(resolvedPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load workflow by name (name-based loading only, no path detection).
|
||||||
|
*
|
||||||
|
* Priority:
|
||||||
|
* 1. Project-local workflows → .takt/workflows/{name}.yaml
|
||||||
|
* 2. User workflows → ~/.takt/workflows/{name}.yaml
|
||||||
|
* 3. Builtin workflows → resources/global/{lang}/workflows/{name}.yaml
|
||||||
|
*
|
||||||
|
* @param name Workflow name (not a file path)
|
||||||
|
* @param projectCwd Project root directory (for project-local workflow resolution)
|
||||||
|
*/
|
||||||
|
export function loadWorkflow(
|
||||||
|
name: string,
|
||||||
|
projectCwd: string
|
||||||
|
): WorkflowConfig | null {
|
||||||
|
// 1. Project-local workflow (.takt/workflows/{name}.yaml)
|
||||||
|
const projectWorkflowsDir = join(getProjectConfigDir(projectCwd), 'workflows');
|
||||||
|
const projectWorkflowPath = join(projectWorkflowsDir, `${name}.yaml`);
|
||||||
|
if (existsSync(projectWorkflowPath)) {
|
||||||
|
return loadWorkflowFromFile(projectWorkflowPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. User workflow (~/.takt/workflows/{name}.yaml)
|
||||||
|
const globalWorkflowsDir = getGlobalWorkflowsDir();
|
||||||
|
const workflowYamlPath = join(globalWorkflowsDir, `${name}.yaml`);
|
||||||
|
if (existsSync(workflowYamlPath)) {
|
||||||
|
return loadWorkflowFromFile(workflowYamlPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Builtin fallback
|
||||||
|
return getBuiltinWorkflow(name);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load all workflows with descriptions (for switch command).
|
||||||
|
*
|
||||||
|
* Priority (later entries override earlier):
|
||||||
|
* 1. Builtin workflows
|
||||||
|
* 2. User workflows (~/.takt/workflows/)
|
||||||
|
* 3. Project-local workflows (.takt/workflows/)
|
||||||
|
*/
|
||||||
|
export function loadAllWorkflows(cwd: string): Map<string, WorkflowConfig> {
|
||||||
|
const workflows = new Map<string, WorkflowConfig>();
|
||||||
|
const disabled = getDisabledBuiltins();
|
||||||
|
|
||||||
|
// 1. Builtin workflows (lowest priority)
|
||||||
|
const lang = getLanguage();
|
||||||
|
const builtinDir = getBuiltinWorkflowsDir(lang);
|
||||||
|
loadWorkflowsFromDir(builtinDir, workflows, disabled);
|
||||||
|
|
||||||
|
// 2. User workflows (overrides builtins)
|
||||||
|
const globalWorkflowsDir = getGlobalWorkflowsDir();
|
||||||
|
loadWorkflowsFromDir(globalWorkflowsDir, workflows);
|
||||||
|
|
||||||
|
// 3. Project-local workflows (highest priority)
|
||||||
|
const projectWorkflowsDir = join(getProjectConfigDir(cwd), 'workflows');
|
||||||
|
loadWorkflowsFromDir(projectWorkflowsDir, workflows);
|
||||||
|
|
||||||
|
return workflows;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Load workflow files from a directory into a Map (later calls override earlier entries) */
|
||||||
|
function loadWorkflowsFromDir(
|
||||||
|
dir: string,
|
||||||
|
target: Map<string, WorkflowConfig>,
|
||||||
|
disabled?: string[],
|
||||||
|
): void {
|
||||||
|
if (!existsSync(dir)) return;
|
||||||
|
for (const entry of readdirSync(dir)) {
|
||||||
|
if (!entry.endsWith('.yaml') && !entry.endsWith('.yml')) continue;
|
||||||
|
const entryPath = join(dir, entry);
|
||||||
|
if (!statSync(entryPath).isFile()) continue;
|
||||||
|
const workflowName = entry.replace(/\.ya?ml$/, '');
|
||||||
|
if (disabled?.includes(workflowName)) continue;
|
||||||
|
try {
|
||||||
|
target.set(workflowName, loadWorkflowFromFile(entryPath));
|
||||||
|
} catch {
|
||||||
|
// Skip invalid workflows
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List available workflow names (builtin + user + project-local, excluding disabled).
|
||||||
|
*
|
||||||
|
* @param cwd Project root directory (used to scan project-local .takt/workflows/).
|
||||||
|
*/
|
||||||
|
export function listWorkflows(cwd: string): string[] {
|
||||||
|
const workflows = new Set<string>();
|
||||||
|
const disabled = getDisabledBuiltins();
|
||||||
|
|
||||||
|
// 1. Builtin workflows
|
||||||
|
const lang = getLanguage();
|
||||||
|
const builtinDir = getBuiltinWorkflowsDir(lang);
|
||||||
|
scanWorkflowDir(builtinDir, workflows, disabled);
|
||||||
|
|
||||||
|
// 2. User workflows
|
||||||
|
const globalWorkflowsDir = getGlobalWorkflowsDir();
|
||||||
|
scanWorkflowDir(globalWorkflowsDir, workflows);
|
||||||
|
|
||||||
|
// 3. Project-local workflows
|
||||||
|
const projectWorkflowsDir = join(getProjectConfigDir(cwd), 'workflows');
|
||||||
|
scanWorkflowDir(projectWorkflowsDir, workflows);
|
||||||
|
|
||||||
|
return Array.from(workflows).sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a workflow identifier looks like a file path (vs a workflow name).
|
||||||
|
*
|
||||||
|
* Path indicators:
|
||||||
|
* - Starts with `/` (absolute path)
|
||||||
|
* - Starts with `~` (home directory)
|
||||||
|
* - Starts with `./` or `../` (relative path)
|
||||||
|
* - Ends with `.yaml` or `.yml` (file extension)
|
||||||
|
*/
|
||||||
|
export function isWorkflowPath(identifier: string): boolean {
|
||||||
|
return (
|
||||||
|
identifier.startsWith('/') ||
|
||||||
|
identifier.startsWith('~') ||
|
||||||
|
identifier.startsWith('./') ||
|
||||||
|
identifier.startsWith('../') ||
|
||||||
|
identifier.endsWith('.yaml') ||
|
||||||
|
identifier.endsWith('.yml')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load workflow by identifier (auto-detects name vs path).
|
||||||
|
*
|
||||||
|
* If the identifier looks like a path (see isWorkflowPath), loads from file.
|
||||||
|
* Otherwise, loads by name with the standard priority chain:
|
||||||
|
* project-local → user → builtin.
|
||||||
|
*
|
||||||
|
* @param identifier Workflow name or file path
|
||||||
|
* @param projectCwd Project root directory (for project-local resolution and relative path base)
|
||||||
|
*/
|
||||||
|
export function loadWorkflowByIdentifier(
|
||||||
|
identifier: string,
|
||||||
|
projectCwd: string
|
||||||
|
): WorkflowConfig | null {
|
||||||
|
if (isWorkflowPath(identifier)) {
|
||||||
|
return loadWorkflowFromPath(identifier, projectCwd);
|
||||||
|
}
|
||||||
|
return loadWorkflow(identifier, projectCwd);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Scan a directory for .yaml/.yml files and add names to the set */
|
||||||
|
function scanWorkflowDir(dir: string, target: Set<string>, disabled?: string[]): void {
|
||||||
|
if (!existsSync(dir)) return;
|
||||||
|
for (const entry of readdirSync(dir)) {
|
||||||
|
if (!entry.endsWith('.yaml') && !entry.endsWith('.yml')) continue;
|
||||||
|
|
||||||
|
const entryPath = join(dir, entry);
|
||||||
|
if (statSync(entryPath).isFile()) {
|
||||||
|
const workflowName = entry.replace(/\.ya?ml$/, '');
|
||||||
|
if (disabled?.includes(workflowName)) continue;
|
||||||
|
target.add(workflowName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
37
src/config/project/index.ts
Normal file
37
src/config/project/index.ts
Normal file
@ -0,0 +1,37 @@
|
|||||||
|
/**
|
||||||
|
* Project configuration - barrel exports
|
||||||
|
*/
|
||||||
|
|
||||||
|
export {
|
||||||
|
loadProjectConfig,
|
||||||
|
saveProjectConfig,
|
||||||
|
updateProjectConfig,
|
||||||
|
getCurrentWorkflow,
|
||||||
|
setCurrentWorkflow,
|
||||||
|
isVerboseMode,
|
||||||
|
type PermissionMode,
|
||||||
|
type ProjectPermissionMode,
|
||||||
|
type ProjectLocalConfig,
|
||||||
|
} from './projectConfig.js';
|
||||||
|
|
||||||
|
export {
|
||||||
|
writeFileAtomic,
|
||||||
|
getInputHistoryPath,
|
||||||
|
MAX_INPUT_HISTORY,
|
||||||
|
loadInputHistory,
|
||||||
|
saveInputHistory,
|
||||||
|
addToInputHistory,
|
||||||
|
type AgentSessionData,
|
||||||
|
getAgentSessionsPath,
|
||||||
|
loadAgentSessions,
|
||||||
|
saveAgentSessions,
|
||||||
|
updateAgentSession,
|
||||||
|
clearAgentSessions,
|
||||||
|
getWorktreeSessionsDir,
|
||||||
|
encodeWorktreePath,
|
||||||
|
getWorktreeSessionPath,
|
||||||
|
loadWorktreeSessions,
|
||||||
|
updateWorktreeSession,
|
||||||
|
getClaudeProjectSessionsDir,
|
||||||
|
clearClaudeProjectSessions,
|
||||||
|
} from './sessionStore.js';
|
||||||
131
src/config/project/projectConfig.ts
Normal file
131
src/config/project/projectConfig.ts
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
/**
|
||||||
|
* Project-level configuration management
|
||||||
|
*
|
||||||
|
* Manages .takt/config.yaml for project-specific settings.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
||||||
|
import { join, resolve } from 'node:path';
|
||||||
|
import { parse, stringify } from 'yaml';
|
||||||
|
import { copyProjectResourcesToDir } from '../../resources/index.js';
|
||||||
|
|
||||||
|
/** Permission mode for the project
|
||||||
|
* - default: Uses Agent SDK's acceptEdits mode (auto-accepts file edits, minimal prompts)
|
||||||
|
* - sacrifice-my-pc: Auto-approves all permission requests (bypassPermissions)
|
||||||
|
*
|
||||||
|
* Note: 'confirm' mode is planned but not yet implemented
|
||||||
|
*/
|
||||||
|
export type PermissionMode = 'default' | 'sacrifice-my-pc';
|
||||||
|
|
||||||
|
/** @deprecated Use PermissionMode instead */
|
||||||
|
export type ProjectPermissionMode = PermissionMode;
|
||||||
|
|
||||||
|
/** Project configuration stored in .takt/config.yaml */
|
||||||
|
export interface ProjectLocalConfig {
|
||||||
|
/** Current workflow name */
|
||||||
|
workflow?: string;
|
||||||
|
/** Provider selection for agent runtime */
|
||||||
|
provider?: 'claude' | 'codex';
|
||||||
|
/** Permission mode setting */
|
||||||
|
permissionMode?: PermissionMode;
|
||||||
|
/** Verbose output mode */
|
||||||
|
verbose?: boolean;
|
||||||
|
/** Custom settings */
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Default project configuration */
|
||||||
|
const DEFAULT_PROJECT_CONFIG: ProjectLocalConfig = {
|
||||||
|
workflow: 'default',
|
||||||
|
permissionMode: 'default',
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get project takt config directory (.takt in project)
|
||||||
|
* Note: Defined locally to avoid circular dependency with paths.ts
|
||||||
|
*/
|
||||||
|
function getConfigDir(projectDir: string): string {
|
||||||
|
return join(resolve(projectDir), '.takt');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get project config file path
|
||||||
|
* Note: Defined locally to avoid circular dependency with paths.ts
|
||||||
|
*/
|
||||||
|
function getConfigPath(projectDir: string): string {
|
||||||
|
return join(getConfigDir(projectDir), 'config.yaml');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Load project configuration from .takt/config.yaml
|
||||||
|
*/
|
||||||
|
export function loadProjectConfig(projectDir: string): ProjectLocalConfig {
|
||||||
|
const configPath = getConfigPath(projectDir);
|
||||||
|
|
||||||
|
if (!existsSync(configPath)) {
|
||||||
|
return { ...DEFAULT_PROJECT_CONFIG };
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const content = readFileSync(configPath, 'utf-8');
|
||||||
|
const parsed = parse(content) as ProjectLocalConfig | null;
|
||||||
|
return { ...DEFAULT_PROJECT_CONFIG, ...parsed };
|
||||||
|
} catch {
|
||||||
|
return { ...DEFAULT_PROJECT_CONFIG };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Save project configuration to .takt/config.yaml
|
||||||
|
*/
|
||||||
|
export function saveProjectConfig(projectDir: string, config: ProjectLocalConfig): void {
|
||||||
|
const configDir = getConfigDir(projectDir);
|
||||||
|
const configPath = getConfigPath(projectDir);
|
||||||
|
|
||||||
|
// Ensure directory exists
|
||||||
|
if (!existsSync(configDir)) {
|
||||||
|
mkdirSync(configDir, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Copy project resources (only copies files that don't exist)
|
||||||
|
copyProjectResourcesToDir(configDir);
|
||||||
|
|
||||||
|
const content = stringify(config, { indent: 2 });
|
||||||
|
writeFileSync(configPath, content, 'utf-8');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a single field in project configuration
|
||||||
|
*/
|
||||||
|
export function updateProjectConfig<K extends keyof ProjectLocalConfig>(
|
||||||
|
projectDir: string,
|
||||||
|
key: K,
|
||||||
|
value: ProjectLocalConfig[K]
|
||||||
|
): void {
|
||||||
|
const config = loadProjectConfig(projectDir);
|
||||||
|
config[key] = value;
|
||||||
|
saveProjectConfig(projectDir, config);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get current workflow from project config
|
||||||
|
*/
|
||||||
|
export function getCurrentWorkflow(projectDir: string): string {
|
||||||
|
const config = loadProjectConfig(projectDir);
|
||||||
|
return config.workflow || 'default';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set current workflow in project config
|
||||||
|
*/
|
||||||
|
export function setCurrentWorkflow(projectDir: string, workflow: string): void {
|
||||||
|
updateProjectConfig(projectDir, 'workflow', workflow);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get verbose mode from project config
|
||||||
|
*/
|
||||||
|
export function isVerboseMode(projectDir: string): boolean {
|
||||||
|
const config = loadProjectConfig(projectDir);
|
||||||
|
return config.verbose === true;
|
||||||
|
}
|
||||||
322
src/config/project/sessionStore.ts
Normal file
322
src/config/project/sessionStore.ts
Normal file
@ -0,0 +1,322 @@
|
|||||||
|
/**
|
||||||
|
* Session storage for takt
|
||||||
|
*
|
||||||
|
* Manages agent sessions and input history persistence.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { existsSync, readFileSync, writeFileSync, renameSync, unlinkSync, readdirSync, rmSync } from 'node:fs';
|
||||||
|
import { join, resolve } from 'node:path';
|
||||||
|
import { homedir } from 'node:os';
|
||||||
|
import { getProjectConfigDir, ensureDir } from '../paths.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Write file atomically using temp file + rename.
|
||||||
|
* This prevents corruption when multiple processes write simultaneously.
|
||||||
|
*/
|
||||||
|
export function writeFileAtomic(filePath: string, content: string): void {
|
||||||
|
const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
|
||||||
|
try {
|
||||||
|
writeFileSync(tempPath, content, 'utf-8');
|
||||||
|
renameSync(tempPath, filePath);
|
||||||
|
} catch (error) {
|
||||||
|
try {
|
||||||
|
if (existsSync(tempPath)) {
|
||||||
|
unlinkSync(tempPath);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore cleanup errors
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Input History ============
|
||||||
|
|
||||||
|
/** Get path for storing input history */
|
||||||
|
export function getInputHistoryPath(projectDir: string): string {
|
||||||
|
return join(getProjectConfigDir(projectDir), 'input_history');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Maximum number of input history entries to keep */
|
||||||
|
export const MAX_INPUT_HISTORY = 100;
|
||||||
|
|
||||||
|
/** Load input history */
|
||||||
|
export function loadInputHistory(projectDir: string): string[] {
|
||||||
|
const path = getInputHistoryPath(projectDir);
|
||||||
|
if (existsSync(path)) {
|
||||||
|
try {
|
||||||
|
const content = readFileSync(path, 'utf-8');
|
||||||
|
return content
|
||||||
|
.split('\n')
|
||||||
|
.filter((line) => line.trim())
|
||||||
|
.map((line) => {
|
||||||
|
try {
|
||||||
|
return JSON.parse(line) as string;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter((entry): entry is string => entry !== null);
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Save input history (atomic write) */
|
||||||
|
export function saveInputHistory(projectDir: string, history: string[]): void {
|
||||||
|
const path = getInputHistoryPath(projectDir);
|
||||||
|
ensureDir(getProjectConfigDir(projectDir));
|
||||||
|
const trimmed = history.slice(-MAX_INPUT_HISTORY);
|
||||||
|
const content = trimmed.map((entry) => JSON.stringify(entry)).join('\n');
|
||||||
|
writeFileAtomic(path, content);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Add an entry to input history */
|
||||||
|
export function addToInputHistory(projectDir: string, input: string): void {
|
||||||
|
const history = loadInputHistory(projectDir);
|
||||||
|
if (history[history.length - 1] !== input) {
|
||||||
|
history.push(input);
|
||||||
|
}
|
||||||
|
saveInputHistory(projectDir, history);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Agent Sessions ============
|
||||||
|
|
||||||
|
/** Agent session data for persistence */
|
||||||
|
export interface AgentSessionData {
|
||||||
|
agentSessions: Record<string, string>;
|
||||||
|
updatedAt: string;
|
||||||
|
/** Provider that created these sessions (claude, codex, etc.) */
|
||||||
|
provider?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get path for storing agent sessions */
|
||||||
|
export function getAgentSessionsPath(projectDir: string): string {
|
||||||
|
return join(getProjectConfigDir(projectDir), 'agent_sessions.json');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Load saved agent sessions. Returns empty if provider has changed. */
|
||||||
|
export function loadAgentSessions(projectDir: string, currentProvider?: string): Record<string, string> {
|
||||||
|
const path = getAgentSessionsPath(projectDir);
|
||||||
|
if (existsSync(path)) {
|
||||||
|
try {
|
||||||
|
const content = readFileSync(path, 'utf-8');
|
||||||
|
const data = JSON.parse(content) as AgentSessionData;
|
||||||
|
// If provider has changed or is unknown (legacy data), sessions are incompatible — discard them
|
||||||
|
if (currentProvider && data.provider !== currentProvider) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
return data.agentSessions || {};
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Save agent sessions (atomic write) */
|
||||||
|
export function saveAgentSessions(
|
||||||
|
projectDir: string,
|
||||||
|
sessions: Record<string, string>,
|
||||||
|
provider?: string
|
||||||
|
): void {
|
||||||
|
const path = getAgentSessionsPath(projectDir);
|
||||||
|
ensureDir(getProjectConfigDir(projectDir));
|
||||||
|
const data: AgentSessionData = {
|
||||||
|
agentSessions: sessions,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
provider,
|
||||||
|
};
|
||||||
|
writeFileAtomic(path, JSON.stringify(data, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update a single agent session atomically.
|
||||||
|
* Uses read-modify-write with atomic file operations.
|
||||||
|
*/
|
||||||
|
export function updateAgentSession(
|
||||||
|
projectDir: string,
|
||||||
|
agentName: string,
|
||||||
|
sessionId: string,
|
||||||
|
provider?: string
|
||||||
|
): void {
|
||||||
|
const path = getAgentSessionsPath(projectDir);
|
||||||
|
ensureDir(getProjectConfigDir(projectDir));
|
||||||
|
|
||||||
|
let sessions: Record<string, string> = {};
|
||||||
|
let existingProvider: string | undefined;
|
||||||
|
if (existsSync(path)) {
|
||||||
|
try {
|
||||||
|
const content = readFileSync(path, 'utf-8');
|
||||||
|
const data = JSON.parse(content) as AgentSessionData;
|
||||||
|
existingProvider = data.provider;
|
||||||
|
// If provider changed, discard old sessions
|
||||||
|
if (provider && existingProvider && existingProvider !== provider) {
|
||||||
|
sessions = {};
|
||||||
|
} else {
|
||||||
|
sessions = data.agentSessions || {};
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
sessions = {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sessions[agentName] = sessionId;
|
||||||
|
|
||||||
|
const data: AgentSessionData = {
|
||||||
|
agentSessions: sessions,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
provider: provider ?? existingProvider,
|
||||||
|
};
|
||||||
|
writeFileAtomic(path, JSON.stringify(data, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Clear all saved agent sessions */
|
||||||
|
export function clearAgentSessions(projectDir: string): void {
|
||||||
|
const path = getAgentSessionsPath(projectDir);
|
||||||
|
ensureDir(getProjectConfigDir(projectDir));
|
||||||
|
const data: AgentSessionData = {
|
||||||
|
agentSessions: {},
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
writeFileAtomic(path, JSON.stringify(data, null, 2));
|
||||||
|
|
||||||
|
// Also clear Claude CLI project sessions
|
||||||
|
clearClaudeProjectSessions(projectDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============ Worktree Sessions ============
|
||||||
|
|
||||||
|
/** Get the worktree sessions directory */
|
||||||
|
export function getWorktreeSessionsDir(projectDir: string): string {
|
||||||
|
return join(getProjectConfigDir(projectDir), 'worktree-sessions');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Encode a worktree path to a safe filename */
|
||||||
|
export function encodeWorktreePath(worktreePath: string): string {
|
||||||
|
const resolved = resolve(worktreePath);
|
||||||
|
return resolved.replace(/[/\\:]/g, '-');
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get path for a worktree's session file */
|
||||||
|
export function getWorktreeSessionPath(projectDir: string, worktreePath: string): string {
|
||||||
|
const dir = getWorktreeSessionsDir(projectDir);
|
||||||
|
const encoded = encodeWorktreePath(worktreePath);
|
||||||
|
return join(dir, `${encoded}.json`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Load saved agent sessions for a worktree. Returns empty if provider has changed. */
|
||||||
|
export function loadWorktreeSessions(
|
||||||
|
projectDir: string,
|
||||||
|
worktreePath: string,
|
||||||
|
currentProvider?: string
|
||||||
|
): Record<string, string> {
|
||||||
|
const sessionPath = getWorktreeSessionPath(projectDir, worktreePath);
|
||||||
|
if (existsSync(sessionPath)) {
|
||||||
|
try {
|
||||||
|
const content = readFileSync(sessionPath, 'utf-8');
|
||||||
|
const data = JSON.parse(content) as AgentSessionData;
|
||||||
|
if (currentProvider && data.provider !== currentProvider) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
return data.agentSessions || {};
|
||||||
|
} catch {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Update a single agent session for a worktree (atomic) */
|
||||||
|
export function updateWorktreeSession(
|
||||||
|
projectDir: string,
|
||||||
|
worktreePath: string,
|
||||||
|
agentName: string,
|
||||||
|
sessionId: string,
|
||||||
|
provider?: string
|
||||||
|
): void {
|
||||||
|
const dir = getWorktreeSessionsDir(projectDir);
|
||||||
|
ensureDir(dir);
|
||||||
|
|
||||||
|
const sessionPath = getWorktreeSessionPath(projectDir, worktreePath);
|
||||||
|
let sessions: Record<string, string> = {};
|
||||||
|
let existingProvider: string | undefined;
|
||||||
|
|
||||||
|
if (existsSync(sessionPath)) {
|
||||||
|
try {
|
||||||
|
const content = readFileSync(sessionPath, 'utf-8');
|
||||||
|
const data = JSON.parse(content) as AgentSessionData;
|
||||||
|
existingProvider = data.provider;
|
||||||
|
if (provider && existingProvider && existingProvider !== provider) {
|
||||||
|
sessions = {};
|
||||||
|
} else {
|
||||||
|
sessions = data.agentSessions || {};
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
sessions = {};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sessions[agentName] = sessionId;
|
||||||
|
|
||||||
|
const data: AgentSessionData = {
|
||||||
|
agentSessions: sessions,
|
||||||
|
updatedAt: new Date().toISOString(),
|
||||||
|
provider: provider ?? existingProvider,
|
||||||
|
};
|
||||||
|
writeFileAtomic(sessionPath, JSON.stringify(data, null, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the Claude CLI project session directory path.
|
||||||
|
* Claude CLI stores sessions in ~/.claude/projects/{encoded-project-path}/
|
||||||
|
*/
|
||||||
|
export function getClaudeProjectSessionsDir(projectDir: string): string {
|
||||||
|
const resolvedPath = resolve(projectDir);
|
||||||
|
// Claude CLI encodes the path by replacing '/' and other special chars with '-'
|
||||||
|
// Based on observed behavior: /Users/takt -> -Users-takt
|
||||||
|
const encodedPath = resolvedPath.replace(/[/\\_ ]/g, '-');
|
||||||
|
return join(homedir(), '.claude', 'projects', encodedPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear Claude CLI project sessions.
|
||||||
|
* Removes all session files (*.jsonl) from the project's session directory.
|
||||||
|
*/
|
||||||
|
export function clearClaudeProjectSessions(projectDir: string): void {
|
||||||
|
const sessionDir = getClaudeProjectSessionsDir(projectDir);
|
||||||
|
|
||||||
|
if (!existsSync(sessionDir)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const entries = readdirSync(sessionDir, { withFileTypes: true });
|
||||||
|
|
||||||
|
for (const entry of entries) {
|
||||||
|
const fullPath = join(sessionDir, entry.name);
|
||||||
|
|
||||||
|
// Remove .jsonl session files and sessions-index.json
|
||||||
|
if (entry.isFile() && (entry.name.endsWith('.jsonl') || entry.name === 'sessions-index.json')) {
|
||||||
|
try {
|
||||||
|
unlinkSync(fullPath);
|
||||||
|
} catch {
|
||||||
|
// Ignore individual file deletion errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove session subdirectories (some sessions have associated directories)
|
||||||
|
if (entry.isDirectory()) {
|
||||||
|
try {
|
||||||
|
rmSync(fullPath, { recursive: true, force: true });
|
||||||
|
} catch {
|
||||||
|
// Ignore directory deletion errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Ignore errors if we can't read the directory
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,131 +1,14 @@
|
|||||||
/**
|
/**
|
||||||
* Project-level configuration management
|
* Re-export shim — actual implementation in project/projectConfig.ts
|
||||||
*
|
|
||||||
* Manages .takt/config.yaml for project-specific settings.
|
|
||||||
*/
|
*/
|
||||||
|
export {
|
||||||
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
|
loadProjectConfig,
|
||||||
import { join, resolve } from 'node:path';
|
saveProjectConfig,
|
||||||
import { parse, stringify } from 'yaml';
|
updateProjectConfig,
|
||||||
import { copyProjectResourcesToDir } from '../resources/index.js';
|
getCurrentWorkflow,
|
||||||
|
setCurrentWorkflow,
|
||||||
/** Permission mode for the project
|
isVerboseMode,
|
||||||
* - default: Uses Agent SDK's acceptEdits mode (auto-accepts file edits, minimal prompts)
|
type PermissionMode,
|
||||||
* - sacrifice-my-pc: Auto-approves all permission requests (bypassPermissions)
|
type ProjectPermissionMode,
|
||||||
*
|
type ProjectLocalConfig,
|
||||||
* Note: 'confirm' mode is planned but not yet implemented
|
} from './project/projectConfig.js';
|
||||||
*/
|
|
||||||
export type PermissionMode = 'default' | 'sacrifice-my-pc';
|
|
||||||
|
|
||||||
/** @deprecated Use PermissionMode instead */
|
|
||||||
export type ProjectPermissionMode = PermissionMode;
|
|
||||||
|
|
||||||
/** Project configuration stored in .takt/config.yaml */
|
|
||||||
export interface ProjectLocalConfig {
|
|
||||||
/** Current workflow name */
|
|
||||||
workflow?: string;
|
|
||||||
/** Provider selection for agent runtime */
|
|
||||||
provider?: 'claude' | 'codex';
|
|
||||||
/** Permission mode setting */
|
|
||||||
permissionMode?: PermissionMode;
|
|
||||||
/** Verbose output mode */
|
|
||||||
verbose?: boolean;
|
|
||||||
/** Custom settings */
|
|
||||||
[key: string]: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Default project configuration */
|
|
||||||
const DEFAULT_PROJECT_CONFIG: ProjectLocalConfig = {
|
|
||||||
workflow: 'default',
|
|
||||||
permissionMode: 'default',
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get project takt config directory (.takt in project)
|
|
||||||
* Note: Defined locally to avoid circular dependency with paths.ts
|
|
||||||
*/
|
|
||||||
function getConfigDir(projectDir: string): string {
|
|
||||||
return join(resolve(projectDir), '.takt');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get project config file path
|
|
||||||
* Note: Defined locally to avoid circular dependency with paths.ts
|
|
||||||
*/
|
|
||||||
function getConfigPath(projectDir: string): string {
|
|
||||||
return join(getConfigDir(projectDir), 'config.yaml');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load project configuration from .takt/config.yaml
|
|
||||||
*/
|
|
||||||
export function loadProjectConfig(projectDir: string): ProjectLocalConfig {
|
|
||||||
const configPath = getConfigPath(projectDir);
|
|
||||||
|
|
||||||
if (!existsSync(configPath)) {
|
|
||||||
return { ...DEFAULT_PROJECT_CONFIG };
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const content = readFileSync(configPath, 'utf-8');
|
|
||||||
const parsed = parse(content) as ProjectLocalConfig | null;
|
|
||||||
return { ...DEFAULT_PROJECT_CONFIG, ...parsed };
|
|
||||||
} catch {
|
|
||||||
return { ...DEFAULT_PROJECT_CONFIG };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Save project configuration to .takt/config.yaml
|
|
||||||
*/
|
|
||||||
export function saveProjectConfig(projectDir: string, config: ProjectLocalConfig): void {
|
|
||||||
const configDir = getConfigDir(projectDir);
|
|
||||||
const configPath = getConfigPath(projectDir);
|
|
||||||
|
|
||||||
// Ensure directory exists
|
|
||||||
if (!existsSync(configDir)) {
|
|
||||||
mkdirSync(configDir, { recursive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Copy project resources (only copies files that don't exist)
|
|
||||||
copyProjectResourcesToDir(configDir);
|
|
||||||
|
|
||||||
const content = stringify(config, { indent: 2 });
|
|
||||||
writeFileSync(configPath, content, 'utf-8');
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update a single field in project configuration
|
|
||||||
*/
|
|
||||||
export function updateProjectConfig<K extends keyof ProjectLocalConfig>(
|
|
||||||
projectDir: string,
|
|
||||||
key: K,
|
|
||||||
value: ProjectLocalConfig[K]
|
|
||||||
): void {
|
|
||||||
const config = loadProjectConfig(projectDir);
|
|
||||||
config[key] = value;
|
|
||||||
saveProjectConfig(projectDir, config);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get current workflow from project config
|
|
||||||
*/
|
|
||||||
export function getCurrentWorkflow(projectDir: string): string {
|
|
||||||
const config = loadProjectConfig(projectDir);
|
|
||||||
return config.workflow || 'default';
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Set current workflow in project config
|
|
||||||
*/
|
|
||||||
export function setCurrentWorkflow(projectDir: string, workflow: string): void {
|
|
||||||
updateProjectConfig(projectDir, 'workflow', workflow);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get verbose mode from project config
|
|
||||||
*/
|
|
||||||
export function isVerboseMode(projectDir: string): boolean {
|
|
||||||
const config = loadProjectConfig(projectDir);
|
|
||||||
return config.verbose === true;
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,322 +1,24 @@
|
|||||||
/**
|
/**
|
||||||
* Session storage for takt
|
* Re-export shim — actual implementation in project/sessionStore.ts
|
||||||
*
|
|
||||||
* Manages agent sessions and input history persistence.
|
|
||||||
*/
|
*/
|
||||||
|
export {
|
||||||
import { existsSync, readFileSync, writeFileSync, renameSync, unlinkSync, readdirSync, rmSync } from 'node:fs';
|
writeFileAtomic,
|
||||||
import { join, resolve } from 'node:path';
|
getInputHistoryPath,
|
||||||
import { homedir } from 'node:os';
|
MAX_INPUT_HISTORY,
|
||||||
import { getProjectConfigDir, ensureDir } from './paths.js';
|
loadInputHistory,
|
||||||
|
saveInputHistory,
|
||||||
/**
|
addToInputHistory,
|
||||||
* Write file atomically using temp file + rename.
|
type AgentSessionData,
|
||||||
* This prevents corruption when multiple processes write simultaneously.
|
getAgentSessionsPath,
|
||||||
*/
|
loadAgentSessions,
|
||||||
export function writeFileAtomic(filePath: string, content: string): void {
|
saveAgentSessions,
|
||||||
const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
|
updateAgentSession,
|
||||||
try {
|
clearAgentSessions,
|
||||||
writeFileSync(tempPath, content, 'utf-8');
|
getWorktreeSessionsDir,
|
||||||
renameSync(tempPath, filePath);
|
encodeWorktreePath,
|
||||||
} catch (error) {
|
getWorktreeSessionPath,
|
||||||
try {
|
loadWorktreeSessions,
|
||||||
if (existsSync(tempPath)) {
|
updateWorktreeSession,
|
||||||
unlinkSync(tempPath);
|
getClaudeProjectSessionsDir,
|
||||||
}
|
clearClaudeProjectSessions,
|
||||||
} catch {
|
} from './project/sessionStore.js';
|
||||||
// Ignore cleanup errors
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============ Input History ============
|
|
||||||
|
|
||||||
/** Get path for storing input history */
|
|
||||||
export function getInputHistoryPath(projectDir: string): string {
|
|
||||||
return join(getProjectConfigDir(projectDir), 'input_history');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Maximum number of input history entries to keep */
|
|
||||||
export const MAX_INPUT_HISTORY = 100;
|
|
||||||
|
|
||||||
/** Load input history */
|
|
||||||
export function loadInputHistory(projectDir: string): string[] {
|
|
||||||
const path = getInputHistoryPath(projectDir);
|
|
||||||
if (existsSync(path)) {
|
|
||||||
try {
|
|
||||||
const content = readFileSync(path, 'utf-8');
|
|
||||||
return content
|
|
||||||
.split('\n')
|
|
||||||
.filter((line) => line.trim())
|
|
||||||
.map((line) => {
|
|
||||||
try {
|
|
||||||
return JSON.parse(line) as string;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.filter((entry): entry is string => entry !== null);
|
|
||||||
} catch {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Save input history (atomic write) */
|
|
||||||
export function saveInputHistory(projectDir: string, history: string[]): void {
|
|
||||||
const path = getInputHistoryPath(projectDir);
|
|
||||||
ensureDir(getProjectConfigDir(projectDir));
|
|
||||||
const trimmed = history.slice(-MAX_INPUT_HISTORY);
|
|
||||||
const content = trimmed.map((entry) => JSON.stringify(entry)).join('\n');
|
|
||||||
writeFileAtomic(path, content);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Add an entry to input history */
|
|
||||||
export function addToInputHistory(projectDir: string, input: string): void {
|
|
||||||
const history = loadInputHistory(projectDir);
|
|
||||||
if (history[history.length - 1] !== input) {
|
|
||||||
history.push(input);
|
|
||||||
}
|
|
||||||
saveInputHistory(projectDir, history);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============ Agent Sessions ============
|
|
||||||
|
|
||||||
/** Agent session data for persistence */
|
|
||||||
export interface AgentSessionData {
|
|
||||||
agentSessions: Record<string, string>;
|
|
||||||
updatedAt: string;
|
|
||||||
/** Provider that created these sessions (claude, codex, etc.) */
|
|
||||||
provider?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Get path for storing agent sessions */
|
|
||||||
export function getAgentSessionsPath(projectDir: string): string {
|
|
||||||
return join(getProjectConfigDir(projectDir), 'agent_sessions.json');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Load saved agent sessions. Returns empty if provider has changed. */
|
|
||||||
export function loadAgentSessions(projectDir: string, currentProvider?: string): Record<string, string> {
|
|
||||||
const path = getAgentSessionsPath(projectDir);
|
|
||||||
if (existsSync(path)) {
|
|
||||||
try {
|
|
||||||
const content = readFileSync(path, 'utf-8');
|
|
||||||
const data = JSON.parse(content) as AgentSessionData;
|
|
||||||
// If provider has changed or is unknown (legacy data), sessions are incompatible — discard them
|
|
||||||
if (currentProvider && data.provider !== currentProvider) {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
return data.agentSessions || {};
|
|
||||||
} catch {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Save agent sessions (atomic write) */
|
|
||||||
export function saveAgentSessions(
|
|
||||||
projectDir: string,
|
|
||||||
sessions: Record<string, string>,
|
|
||||||
provider?: string
|
|
||||||
): void {
|
|
||||||
const path = getAgentSessionsPath(projectDir);
|
|
||||||
ensureDir(getProjectConfigDir(projectDir));
|
|
||||||
const data: AgentSessionData = {
|
|
||||||
agentSessions: sessions,
|
|
||||||
updatedAt: new Date().toISOString(),
|
|
||||||
provider,
|
|
||||||
};
|
|
||||||
writeFileAtomic(path, JSON.stringify(data, null, 2));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Update a single agent session atomically.
|
|
||||||
* Uses read-modify-write with atomic file operations.
|
|
||||||
*/
|
|
||||||
export function updateAgentSession(
|
|
||||||
projectDir: string,
|
|
||||||
agentName: string,
|
|
||||||
sessionId: string,
|
|
||||||
provider?: string
|
|
||||||
): void {
|
|
||||||
const path = getAgentSessionsPath(projectDir);
|
|
||||||
ensureDir(getProjectConfigDir(projectDir));
|
|
||||||
|
|
||||||
let sessions: Record<string, string> = {};
|
|
||||||
let existingProvider: string | undefined;
|
|
||||||
if (existsSync(path)) {
|
|
||||||
try {
|
|
||||||
const content = readFileSync(path, 'utf-8');
|
|
||||||
const data = JSON.parse(content) as AgentSessionData;
|
|
||||||
existingProvider = data.provider;
|
|
||||||
// If provider changed, discard old sessions
|
|
||||||
if (provider && existingProvider && existingProvider !== provider) {
|
|
||||||
sessions = {};
|
|
||||||
} else {
|
|
||||||
sessions = data.agentSessions || {};
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
sessions = {};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
sessions[agentName] = sessionId;
|
|
||||||
|
|
||||||
const data: AgentSessionData = {
|
|
||||||
agentSessions: sessions,
|
|
||||||
updatedAt: new Date().toISOString(),
|
|
||||||
provider: provider ?? existingProvider,
|
|
||||||
};
|
|
||||||
writeFileAtomic(path, JSON.stringify(data, null, 2));
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Clear all saved agent sessions */
|
|
||||||
export function clearAgentSessions(projectDir: string): void {
|
|
||||||
const path = getAgentSessionsPath(projectDir);
|
|
||||||
ensureDir(getProjectConfigDir(projectDir));
|
|
||||||
const data: AgentSessionData = {
|
|
||||||
agentSessions: {},
|
|
||||||
updatedAt: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
writeFileAtomic(path, JSON.stringify(data, null, 2));
|
|
||||||
|
|
||||||
// Also clear Claude CLI project sessions
|
|
||||||
clearClaudeProjectSessions(projectDir);
|
|
||||||
}
|
|
||||||
|
|
||||||
// ============ Worktree Sessions ============
|
|
||||||
|
|
||||||
/** Get the worktree sessions directory */
|
|
||||||
export function getWorktreeSessionsDir(projectDir: string): string {
|
|
||||||
return join(getProjectConfigDir(projectDir), 'worktree-sessions');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Encode a worktree path to a safe filename */
|
|
||||||
export function encodeWorktreePath(worktreePath: string): string {
|
|
||||||
const resolved = resolve(worktreePath);
|
|
||||||
return resolved.replace(/[/\\:]/g, '-');
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Get path for a worktree's session file */
|
|
||||||
export function getWorktreeSessionPath(projectDir: string, worktreePath: string): string {
|
|
||||||
const dir = getWorktreeSessionsDir(projectDir);
|
|
||||||
const encoded = encodeWorktreePath(worktreePath);
|
|
||||||
return join(dir, `${encoded}.json`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Load saved agent sessions for a worktree. Returns empty if provider has changed. */
|
|
||||||
export function loadWorktreeSessions(
|
|
||||||
projectDir: string,
|
|
||||||
worktreePath: string,
|
|
||||||
currentProvider?: string
|
|
||||||
): Record<string, string> {
|
|
||||||
const sessionPath = getWorktreeSessionPath(projectDir, worktreePath);
|
|
||||||
if (existsSync(sessionPath)) {
|
|
||||||
try {
|
|
||||||
const content = readFileSync(sessionPath, 'utf-8');
|
|
||||||
const data = JSON.parse(content) as AgentSessionData;
|
|
||||||
if (currentProvider && data.provider !== currentProvider) {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
return data.agentSessions || {};
|
|
||||||
} catch {
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return {};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Update a single agent session for a worktree (atomic) */
|
|
||||||
export function updateWorktreeSession(
|
|
||||||
projectDir: string,
|
|
||||||
worktreePath: string,
|
|
||||||
agentName: string,
|
|
||||||
sessionId: string,
|
|
||||||
provider?: string
|
|
||||||
): void {
|
|
||||||
const dir = getWorktreeSessionsDir(projectDir);
|
|
||||||
ensureDir(dir);
|
|
||||||
|
|
||||||
const sessionPath = getWorktreeSessionPath(projectDir, worktreePath);
|
|
||||||
let sessions: Record<string, string> = {};
|
|
||||||
let existingProvider: string | undefined;
|
|
||||||
|
|
||||||
if (existsSync(sessionPath)) {
|
|
||||||
try {
|
|
||||||
const content = readFileSync(sessionPath, 'utf-8');
|
|
||||||
const data = JSON.parse(content) as AgentSessionData;
|
|
||||||
existingProvider = data.provider;
|
|
||||||
if (provider && existingProvider && existingProvider !== provider) {
|
|
||||||
sessions = {};
|
|
||||||
} else {
|
|
||||||
sessions = data.agentSessions || {};
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
sessions = {};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
sessions[agentName] = sessionId;
|
|
||||||
|
|
||||||
const data: AgentSessionData = {
|
|
||||||
agentSessions: sessions,
|
|
||||||
updatedAt: new Date().toISOString(),
|
|
||||||
provider: provider ?? existingProvider,
|
|
||||||
};
|
|
||||||
writeFileAtomic(sessionPath, JSON.stringify(data, null, 2));
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Get the Claude CLI project session directory path.
|
|
||||||
* Claude CLI stores sessions in ~/.claude/projects/{encoded-project-path}/
|
|
||||||
*/
|
|
||||||
export function getClaudeProjectSessionsDir(projectDir: string): string {
|
|
||||||
const resolvedPath = resolve(projectDir);
|
|
||||||
// Claude CLI encodes the path by replacing '/' and other special chars with '-'
|
|
||||||
// Based on observed behavior: /Users/takt -> -Users-takt
|
|
||||||
const encodedPath = resolvedPath.replace(/[/\\_ ]/g, '-');
|
|
||||||
return join(homedir(), '.claude', 'projects', encodedPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Clear Claude CLI project sessions.
|
|
||||||
* Removes all session files (*.jsonl) from the project's session directory.
|
|
||||||
*/
|
|
||||||
export function clearClaudeProjectSessions(projectDir: string): void {
|
|
||||||
const sessionDir = getClaudeProjectSessionsDir(projectDir);
|
|
||||||
|
|
||||||
if (!existsSync(sessionDir)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const entries = readdirSync(sessionDir, { withFileTypes: true });
|
|
||||||
|
|
||||||
for (const entry of entries) {
|
|
||||||
const fullPath = join(sessionDir, entry.name);
|
|
||||||
|
|
||||||
// Remove .jsonl session files and sessions-index.json
|
|
||||||
if (entry.isFile() && (entry.name.endsWith('.jsonl') || entry.name === 'sessions-index.json')) {
|
|
||||||
try {
|
|
||||||
unlinkSync(fullPath);
|
|
||||||
} catch {
|
|
||||||
// Ignore individual file deletion errors
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Remove session subdirectories (some sessions have associated directories)
|
|
||||||
if (entry.isDirectory()) {
|
|
||||||
try {
|
|
||||||
rmSync(fullPath, { recursive: true, force: true });
|
|
||||||
} catch {
|
|
||||||
// Ignore directory deletion errors
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Ignore errors if we can't read the directory
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -1,449 +1,11 @@
|
|||||||
/**
|
/**
|
||||||
* Workflow configuration loader
|
* Re-export shim — actual implementation in loaders/workflowLoader.ts
|
||||||
*
|
|
||||||
* Loads workflows with the following priority:
|
|
||||||
* 1. Path-based input (absolute, relative, or home-dir) → load directly from file
|
|
||||||
* 2. Project-local workflows: .takt/workflows/{name}.yaml
|
|
||||||
* 3. User workflows: ~/.takt/workflows/{name}.yaml
|
|
||||||
* 4. Builtin workflows: resources/global/{lang}/workflows/{name}.yaml
|
|
||||||
*/
|
*/
|
||||||
|
export {
|
||||||
import { readFileSync, existsSync, readdirSync, statSync } from 'node:fs';
|
getBuiltinWorkflow,
|
||||||
import { join, dirname, basename, resolve, isAbsolute } from 'node:path';
|
loadWorkflow,
|
||||||
import { homedir } from 'node:os';
|
loadWorkflowByIdentifier,
|
||||||
import { parse as parseYaml } from 'yaml';
|
isWorkflowPath,
|
||||||
import { WorkflowConfigRawSchema } from '../models/schemas.js';
|
loadAllWorkflows,
|
||||||
import type { WorkflowConfig, WorkflowStep, WorkflowRule, ReportConfig, ReportObjectConfig } from '../models/types.js';
|
listWorkflows,
|
||||||
import { getGlobalWorkflowsDir, getBuiltinWorkflowsDir, getProjectConfigDir } from './paths.js';
|
} from './loaders/workflowLoader.js';
|
||||||
import { getLanguage, getDisabledBuiltins } from './globalConfig.js';
|
|
||||||
|
|
||||||
/** Get builtin workflow by name */
|
|
||||||
export function getBuiltinWorkflow(name: string): WorkflowConfig | null {
|
|
||||||
const lang = getLanguage();
|
|
||||||
const disabled = getDisabledBuiltins();
|
|
||||||
if (disabled.includes(name)) return null;
|
|
||||||
|
|
||||||
const builtinDir = getBuiltinWorkflowsDir(lang);
|
|
||||||
const yamlPath = join(builtinDir, `${name}.yaml`);
|
|
||||||
if (existsSync(yamlPath)) {
|
|
||||||
return loadWorkflowFromFile(yamlPath);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve agent path from workflow specification.
|
|
||||||
* - Relative path (./agent.md): relative to workflow directory
|
|
||||||
* - Absolute path (/path/to/agent.md or ~/...): use as-is
|
|
||||||
*/
|
|
||||||
function resolveAgentPathForWorkflow(agentSpec: string, workflowDir: string): string {
|
|
||||||
// Relative path (starts with ./)
|
|
||||||
if (agentSpec.startsWith('./')) {
|
|
||||||
return join(workflowDir, agentSpec.slice(2));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Home directory expansion
|
|
||||||
if (agentSpec.startsWith('~')) {
|
|
||||||
const homedir = process.env.HOME || process.env.USERPROFILE || '';
|
|
||||||
return join(homedir, agentSpec.slice(1));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Absolute path
|
|
||||||
if (agentSpec.startsWith('/')) {
|
|
||||||
return agentSpec;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fallback: treat as relative to workflow directory
|
|
||||||
return join(workflowDir, agentSpec);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Extract display name from agent path.
|
|
||||||
* e.g., "~/.takt/agents/default/coder.md" -> "coder"
|
|
||||||
*/
|
|
||||||
function extractAgentDisplayName(agentPath: string): string {
|
|
||||||
// Get the filename without extension
|
|
||||||
const filename = basename(agentPath, '.md');
|
|
||||||
return filename;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve a string value that may be a file path.
|
|
||||||
* If the value ends with .md and the file exists (resolved relative to workflowDir),
|
|
||||||
* read and return the file contents. Otherwise return the value as-is.
|
|
||||||
*/
|
|
||||||
function resolveContentPath(value: string | undefined, workflowDir: string): string | undefined {
|
|
||||||
if (value == null) return undefined;
|
|
||||||
if (value.endsWith('.md')) {
|
|
||||||
// Resolve path relative to workflow directory
|
|
||||||
let resolvedPath = value;
|
|
||||||
if (value.startsWith('./')) {
|
|
||||||
resolvedPath = join(workflowDir, value.slice(2));
|
|
||||||
} else if (value.startsWith('~')) {
|
|
||||||
const homedir = process.env.HOME || process.env.USERPROFILE || '';
|
|
||||||
resolvedPath = join(homedir, value.slice(1));
|
|
||||||
} else if (!value.startsWith('/')) {
|
|
||||||
resolvedPath = join(workflowDir, value);
|
|
||||||
}
|
|
||||||
if (existsSync(resolvedPath)) {
|
|
||||||
return readFileSync(resolvedPath, 'utf-8');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a raw report value is the object form (has 'name' property).
|
|
||||||
*/
|
|
||||||
function isReportObject(raw: unknown): raw is { name: string; order?: string; format?: string } {
|
|
||||||
return typeof raw === 'object' && raw !== null && !Array.isArray(raw) && 'name' in raw;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Normalize the raw report field from YAML into internal format.
|
|
||||||
*
|
|
||||||
* YAML formats:
|
|
||||||
* report: "00-plan.md" → string (single file)
|
|
||||||
* report: → ReportConfig[] (multiple files)
|
|
||||||
* - Scope: 01-scope.md
|
|
||||||
* - Decisions: 02-decisions.md
|
|
||||||
* report: → ReportObjectConfig (object form)
|
|
||||||
* name: 00-plan.md
|
|
||||||
* order: ...
|
|
||||||
* format: ...
|
|
||||||
*
|
|
||||||
* Array items are parsed as single-key objects: [{Scope: "01-scope.md"}, ...]
|
|
||||||
*/
|
|
||||||
function normalizeReport(
|
|
||||||
raw: string | Record<string, string>[] | { name: string; order?: string; format?: string } | undefined,
|
|
||||||
workflowDir: string,
|
|
||||||
): string | ReportConfig[] | ReportObjectConfig | undefined {
|
|
||||||
if (raw == null) return undefined;
|
|
||||||
if (typeof raw === 'string') return raw;
|
|
||||||
if (isReportObject(raw)) {
|
|
||||||
return {
|
|
||||||
name: raw.name,
|
|
||||||
order: resolveContentPath(raw.order, workflowDir),
|
|
||||||
format: resolveContentPath(raw.format, workflowDir),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
// Convert [{Scope: "01-scope.md"}, ...] to [{label: "Scope", path: "01-scope.md"}, ...]
|
|
||||||
return (raw as Record<string, string>[]).flatMap((entry) =>
|
|
||||||
Object.entries(entry).map(([label, path]) => ({ label, path })),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Regex to detect ai("...") condition expressions */
|
|
||||||
const AI_CONDITION_REGEX = /^ai\("(.+)"\)$/;
|
|
||||||
|
|
||||||
/** Regex to detect all("...")/any("...") aggregate condition expressions */
|
|
||||||
const AGGREGATE_CONDITION_REGEX = /^(all|any)\("(.+)"\)$/;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse a rule's condition for ai() and all()/any() expressions.
|
|
||||||
* - `ai("text")` → sets isAiCondition and aiConditionText
|
|
||||||
* - `all("text")` / `any("text")` → sets isAggregateCondition, aggregateType, aggregateConditionText
|
|
||||||
*/
|
|
||||||
function normalizeRule(r: { condition: string; next: string; appendix?: string }): WorkflowRule {
|
|
||||||
const aiMatch = r.condition.match(AI_CONDITION_REGEX);
|
|
||||||
if (aiMatch?.[1]) {
|
|
||||||
return {
|
|
||||||
condition: r.condition,
|
|
||||||
next: r.next,
|
|
||||||
appendix: r.appendix,
|
|
||||||
isAiCondition: true,
|
|
||||||
aiConditionText: aiMatch[1],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const aggMatch = r.condition.match(AGGREGATE_CONDITION_REGEX);
|
|
||||||
if (aggMatch?.[1] && aggMatch[2]) {
|
|
||||||
return {
|
|
||||||
condition: r.condition,
|
|
||||||
next: r.next,
|
|
||||||
appendix: r.appendix,
|
|
||||||
isAggregateCondition: true,
|
|
||||||
aggregateType: aggMatch[1] as 'all' | 'any',
|
|
||||||
aggregateConditionText: aggMatch[2],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
condition: r.condition,
|
|
||||||
next: r.next,
|
|
||||||
appendix: r.appendix,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
||||||
type RawStep = any;
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Normalize a raw step into internal WorkflowStep format.
|
|
||||||
*/
|
|
||||||
function normalizeStepFromRaw(step: RawStep, workflowDir: string): WorkflowStep {
|
|
||||||
const rules: WorkflowRule[] | undefined = step.rules?.map(normalizeRule);
|
|
||||||
const agentSpec: string = step.agent ?? '';
|
|
||||||
|
|
||||||
const result: WorkflowStep = {
|
|
||||||
name: step.name,
|
|
||||||
agent: agentSpec,
|
|
||||||
agentDisplayName: step.agent_name || (agentSpec ? extractAgentDisplayName(agentSpec) : step.name),
|
|
||||||
agentPath: agentSpec ? resolveAgentPathForWorkflow(agentSpec, workflowDir) : undefined,
|
|
||||||
allowedTools: step.allowed_tools,
|
|
||||||
provider: step.provider,
|
|
||||||
model: step.model,
|
|
||||||
permissionMode: step.permission_mode,
|
|
||||||
edit: step.edit,
|
|
||||||
instructionTemplate: resolveContentPath(step.instruction_template, workflowDir) || step.instruction || '{task}',
|
|
||||||
rules,
|
|
||||||
report: normalizeReport(step.report, workflowDir),
|
|
||||||
passPreviousResponse: step.pass_previous_response ?? true,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (step.parallel && step.parallel.length > 0) {
|
|
||||||
result.parallel = step.parallel.map((sub: RawStep) => normalizeStepFromRaw(sub, workflowDir));
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert raw YAML workflow config to internal format.
|
|
||||||
* Agent paths are resolved relative to the workflow directory.
|
|
||||||
*/
|
|
||||||
function normalizeWorkflowConfig(raw: unknown, workflowDir: string): WorkflowConfig {
|
|
||||||
const parsed = WorkflowConfigRawSchema.parse(raw);
|
|
||||||
|
|
||||||
const steps: WorkflowStep[] = parsed.steps.map((step) =>
|
|
||||||
normalizeStepFromRaw(step, workflowDir),
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
|
||||||
name: parsed.name,
|
|
||||||
description: parsed.description,
|
|
||||||
steps,
|
|
||||||
initialStep: parsed.initial_step || steps[0]?.name || '',
|
|
||||||
maxIterations: parsed.max_iterations,
|
|
||||||
answerAgent: parsed.answer_agent,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load a workflow from a YAML file.
|
|
||||||
* @param filePath Path to the workflow YAML file
|
|
||||||
*/
|
|
||||||
function loadWorkflowFromFile(filePath: string): WorkflowConfig {
|
|
||||||
if (!existsSync(filePath)) {
|
|
||||||
throw new Error(`Workflow file not found: ${filePath}`);
|
|
||||||
}
|
|
||||||
const content = readFileSync(filePath, 'utf-8');
|
|
||||||
const raw = parseYaml(content);
|
|
||||||
const workflowDir = dirname(filePath);
|
|
||||||
return normalizeWorkflowConfig(raw, workflowDir);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Resolve a path that may be relative, absolute, or home-directory-relative.
|
|
||||||
* @param pathInput Path to resolve
|
|
||||||
* @param basePath Base directory for relative paths
|
|
||||||
* @returns Absolute resolved path
|
|
||||||
*/
|
|
||||||
function resolvePath(pathInput: string, basePath: string): string {
|
|
||||||
// Home directory expansion
|
|
||||||
if (pathInput.startsWith('~')) {
|
|
||||||
const home = homedir();
|
|
||||||
return resolve(home, pathInput.slice(1).replace(/^\//, ''));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Absolute path
|
|
||||||
if (isAbsolute(pathInput)) {
|
|
||||||
return pathInput;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Relative path
|
|
||||||
return resolve(basePath, pathInput);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load workflow from a file path.
|
|
||||||
* Called internally by loadWorkflowByIdentifier when the identifier is detected as a path.
|
|
||||||
*
|
|
||||||
* @param filePath Path to workflow file (absolute, relative, or home-dir prefixed with ~)
|
|
||||||
* @param basePath Base directory for resolving relative paths
|
|
||||||
* @returns WorkflowConfig or null if file not found
|
|
||||||
*/
|
|
||||||
function loadWorkflowFromPath(
|
|
||||||
filePath: string,
|
|
||||||
basePath: string
|
|
||||||
): WorkflowConfig | null {
|
|
||||||
const resolvedPath = resolvePath(filePath, basePath);
|
|
||||||
|
|
||||||
if (!existsSync(resolvedPath)) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return loadWorkflowFromFile(resolvedPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load workflow by name (name-based loading only, no path detection).
|
|
||||||
*
|
|
||||||
* Priority:
|
|
||||||
* 1. Project-local workflows → .takt/workflows/{name}.yaml
|
|
||||||
* 2. User workflows → ~/.takt/workflows/{name}.yaml
|
|
||||||
* 3. Builtin workflows → resources/global/{lang}/workflows/{name}.yaml
|
|
||||||
*
|
|
||||||
* @param name Workflow name (not a file path)
|
|
||||||
* @param projectCwd Project root directory (for project-local workflow resolution)
|
|
||||||
*/
|
|
||||||
export function loadWorkflow(
|
|
||||||
name: string,
|
|
||||||
projectCwd: string
|
|
||||||
): WorkflowConfig | null {
|
|
||||||
// 1. Project-local workflow (.takt/workflows/{name}.yaml)
|
|
||||||
const projectWorkflowsDir = join(getProjectConfigDir(projectCwd), 'workflows');
|
|
||||||
const projectWorkflowPath = join(projectWorkflowsDir, `${name}.yaml`);
|
|
||||||
if (existsSync(projectWorkflowPath)) {
|
|
||||||
return loadWorkflowFromFile(projectWorkflowPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. User workflow (~/.takt/workflows/{name}.yaml)
|
|
||||||
const globalWorkflowsDir = getGlobalWorkflowsDir();
|
|
||||||
const workflowYamlPath = join(globalWorkflowsDir, `${name}.yaml`);
|
|
||||||
if (existsSync(workflowYamlPath)) {
|
|
||||||
return loadWorkflowFromFile(workflowYamlPath);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Builtin fallback
|
|
||||||
return getBuiltinWorkflow(name);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load all workflows with descriptions (for switch command).
|
|
||||||
*
|
|
||||||
* Priority (later entries override earlier):
|
|
||||||
* 1. Builtin workflows
|
|
||||||
* 2. User workflows (~/.takt/workflows/)
|
|
||||||
* 3. Project-local workflows (.takt/workflows/)
|
|
||||||
*/
|
|
||||||
export function loadAllWorkflows(cwd: string): Map<string, WorkflowConfig> {
|
|
||||||
const workflows = new Map<string, WorkflowConfig>();
|
|
||||||
const disabled = getDisabledBuiltins();
|
|
||||||
|
|
||||||
// 1. Builtin workflows (lowest priority)
|
|
||||||
const lang = getLanguage();
|
|
||||||
const builtinDir = getBuiltinWorkflowsDir(lang);
|
|
||||||
loadWorkflowsFromDir(builtinDir, workflows, disabled);
|
|
||||||
|
|
||||||
// 2. User workflows (overrides builtins)
|
|
||||||
const globalWorkflowsDir = getGlobalWorkflowsDir();
|
|
||||||
loadWorkflowsFromDir(globalWorkflowsDir, workflows);
|
|
||||||
|
|
||||||
// 3. Project-local workflows (highest priority)
|
|
||||||
const projectWorkflowsDir = join(getProjectConfigDir(cwd), 'workflows');
|
|
||||||
loadWorkflowsFromDir(projectWorkflowsDir, workflows);
|
|
||||||
|
|
||||||
return workflows;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Load workflow files from a directory into a Map (later calls override earlier entries) */
|
|
||||||
function loadWorkflowsFromDir(
|
|
||||||
dir: string,
|
|
||||||
target: Map<string, WorkflowConfig>,
|
|
||||||
disabled?: string[],
|
|
||||||
): void {
|
|
||||||
if (!existsSync(dir)) return;
|
|
||||||
for (const entry of readdirSync(dir)) {
|
|
||||||
if (!entry.endsWith('.yaml') && !entry.endsWith('.yml')) continue;
|
|
||||||
const entryPath = join(dir, entry);
|
|
||||||
if (!statSync(entryPath).isFile()) continue;
|
|
||||||
const workflowName = entry.replace(/\.ya?ml$/, '');
|
|
||||||
if (disabled?.includes(workflowName)) continue;
|
|
||||||
try {
|
|
||||||
target.set(workflowName, loadWorkflowFromFile(entryPath));
|
|
||||||
} catch {
|
|
||||||
// Skip invalid workflows
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* List available workflow names (builtin + user + project-local, excluding disabled).
|
|
||||||
*
|
|
||||||
* @param cwd Project root directory (used to scan project-local .takt/workflows/).
|
|
||||||
*/
|
|
||||||
export function listWorkflows(cwd: string): string[] {
|
|
||||||
const workflows = new Set<string>();
|
|
||||||
const disabled = getDisabledBuiltins();
|
|
||||||
|
|
||||||
// 1. Builtin workflows
|
|
||||||
const lang = getLanguage();
|
|
||||||
const builtinDir = getBuiltinWorkflowsDir(lang);
|
|
||||||
scanWorkflowDir(builtinDir, workflows, disabled);
|
|
||||||
|
|
||||||
// 2. User workflows
|
|
||||||
const globalWorkflowsDir = getGlobalWorkflowsDir();
|
|
||||||
scanWorkflowDir(globalWorkflowsDir, workflows);
|
|
||||||
|
|
||||||
// 3. Project-local workflows
|
|
||||||
const projectWorkflowsDir = join(getProjectConfigDir(cwd), 'workflows');
|
|
||||||
scanWorkflowDir(projectWorkflowsDir, workflows);
|
|
||||||
|
|
||||||
return Array.from(workflows).sort();
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Check if a workflow identifier looks like a file path (vs a workflow name).
|
|
||||||
*
|
|
||||||
* Path indicators:
|
|
||||||
* - Starts with `/` (absolute path)
|
|
||||||
* - Starts with `~` (home directory)
|
|
||||||
* - Starts with `./` or `../` (relative path)
|
|
||||||
* - Ends with `.yaml` or `.yml` (file extension)
|
|
||||||
*/
|
|
||||||
export function isWorkflowPath(identifier: string): boolean {
|
|
||||||
return (
|
|
||||||
identifier.startsWith('/') ||
|
|
||||||
identifier.startsWith('~') ||
|
|
||||||
identifier.startsWith('./') ||
|
|
||||||
identifier.startsWith('../') ||
|
|
||||||
identifier.endsWith('.yaml') ||
|
|
||||||
identifier.endsWith('.yml')
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Load workflow by identifier (auto-detects name vs path).
|
|
||||||
*
|
|
||||||
* If the identifier looks like a path (see isWorkflowPath), loads from file.
|
|
||||||
* Otherwise, loads by name with the standard priority chain:
|
|
||||||
* project-local → user → builtin.
|
|
||||||
*
|
|
||||||
* @param identifier Workflow name or file path
|
|
||||||
* @param projectCwd Project root directory (for project-local resolution and relative path base)
|
|
||||||
*/
|
|
||||||
export function loadWorkflowByIdentifier(
|
|
||||||
identifier: string,
|
|
||||||
projectCwd: string
|
|
||||||
): WorkflowConfig | null {
|
|
||||||
if (isWorkflowPath(identifier)) {
|
|
||||||
return loadWorkflowFromPath(identifier, projectCwd);
|
|
||||||
}
|
|
||||||
return loadWorkflow(identifier, projectCwd);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Scan a directory for .yaml/.yml files and add names to the set */
|
|
||||||
function scanWorkflowDir(dir: string, target: Set<string>, disabled?: string[]): void {
|
|
||||||
if (!existsSync(dir)) return;
|
|
||||||
for (const entry of readdirSync(dir)) {
|
|
||||||
if (!entry.endsWith('.yaml') && !entry.endsWith('.yml')) continue;
|
|
||||||
|
|
||||||
const entryPath = join(dir, entry);
|
|
||||||
if (statSync(entryPath).isFile()) {
|
|
||||||
const workflowName = entry.replace(/\.ya?ml$/, '');
|
|
||||||
if (disabled?.includes(workflowName)) continue;
|
|
||||||
target.add(workflowName);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@ -3,17 +3,47 @@
|
|||||||
*
|
*
|
||||||
* Holds process-wide state (quiet mode, etc.) that would otherwise
|
* Holds process-wide state (quiet mode, etc.) that would otherwise
|
||||||
* create circular dependencies if exported from cli.ts.
|
* create circular dependencies if exported from cli.ts.
|
||||||
|
*
|
||||||
|
* AppContext is a singleton — use AppContext.getInstance() or
|
||||||
|
* the module-level convenience functions isQuietMode / setQuietMode.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/** Whether quiet mode is active (set during CLI initialization) */
|
export class AppContext {
|
||||||
let quietMode = false;
|
private static instance: AppContext | null = null;
|
||||||
|
|
||||||
|
private quietMode = false;
|
||||||
|
|
||||||
|
private constructor() {}
|
||||||
|
|
||||||
|
static getInstance(): AppContext {
|
||||||
|
if (!AppContext.instance) {
|
||||||
|
AppContext.instance = new AppContext();
|
||||||
|
}
|
||||||
|
return AppContext.instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reset singleton for testing */
|
||||||
|
static resetInstance(): void {
|
||||||
|
AppContext.instance = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
getQuietMode(): boolean {
|
||||||
|
return this.quietMode;
|
||||||
|
}
|
||||||
|
|
||||||
|
setQuietMode(value: boolean): void {
|
||||||
|
this.quietMode = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Backward-compatible module-level functions ----
|
||||||
|
|
||||||
/** Get whether quiet mode is active (CLI flag or config, resolved in preAction) */
|
/** Get whether quiet mode is active (CLI flag or config, resolved in preAction) */
|
||||||
export function isQuietMode(): boolean {
|
export function isQuietMode(): boolean {
|
||||||
return quietMode;
|
return AppContext.getInstance().getQuietMode();
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Set quiet mode state. Called from CLI preAction hook. */
|
/** Set quiet mode state. Called from CLI preAction hook. */
|
||||||
export function setQuietMode(value: boolean): void {
|
export function setQuietMode(value: boolean): void {
|
||||||
quietMode = value;
|
AppContext.getInstance().setQuietMode(value);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,38 +1,14 @@
|
|||||||
/**
|
/**
|
||||||
* Workflow execution engine
|
* Re-export shim for backward compatibility.
|
||||||
|
*
|
||||||
|
* The actual implementation has been split into:
|
||||||
|
* - engine/WorkflowEngine.ts — Main orchestration loop
|
||||||
|
* - engine/StepExecutor.ts — Single-step 3-phase execution
|
||||||
|
* - engine/ParallelRunner.ts — Parallel step execution
|
||||||
|
* - engine/OptionsBuilder.ts — RunAgentOptions construction
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { EventEmitter } from 'node:events';
|
export { WorkflowEngine } from './engine/WorkflowEngine.js';
|
||||||
import { mkdirSync, existsSync, symlinkSync } from 'node:fs';
|
|
||||||
import { join } from 'node:path';
|
|
||||||
import type {
|
|
||||||
WorkflowConfig,
|
|
||||||
WorkflowState,
|
|
||||||
WorkflowStep,
|
|
||||||
AgentResponse,
|
|
||||||
} from '../models/types.js';
|
|
||||||
import { runAgent, type RunAgentOptions } from '../agents/runner.js';
|
|
||||||
import { COMPLETE_STEP, ABORT_STEP, ERROR_MESSAGES } from './constants.js';
|
|
||||||
import type { WorkflowEngineOptions } from './types.js';
|
|
||||||
import { determineNextStepByRules } from './transitions.js';
|
|
||||||
import { buildInstruction as buildInstructionFromTemplate, isReportObjectConfig } from './instruction-builder.js';
|
|
||||||
import { LoopDetector } from './loop-detector.js';
|
|
||||||
import { handleBlocked } from './blocked-handler.js';
|
|
||||||
import { ParallelLogger } from './parallel-logger.js';
|
|
||||||
import { detectMatchedRule } from './rule-evaluator.js';
|
|
||||||
import { needsStatusJudgmentPhase, runReportPhase, runStatusJudgmentPhase } from './phase-runner.js';
|
|
||||||
import {
|
|
||||||
createInitialState,
|
|
||||||
addUserInput,
|
|
||||||
getPreviousOutput,
|
|
||||||
incrementStepIteration,
|
|
||||||
} from './state-manager.js';
|
|
||||||
import { generateReportDir } from '../utils/session.js';
|
|
||||||
import { getErrorMessage } from '../utils/error.js';
|
|
||||||
import { createLogger } from '../utils/debug.js';
|
|
||||||
import { interruptAllQueries } from '../claude/query-manager.js';
|
|
||||||
|
|
||||||
const log = createLogger('engine');
|
|
||||||
|
|
||||||
// Re-export types for backward compatibility
|
// Re-export types for backward compatibility
|
||||||
export type {
|
export type {
|
||||||
@ -44,562 +20,3 @@ export type {
|
|||||||
WorkflowEngineOptions,
|
WorkflowEngineOptions,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
export { COMPLETE_STEP, ABORT_STEP } from './constants.js';
|
export { COMPLETE_STEP, ABORT_STEP } from './constants.js';
|
||||||
|
|
||||||
/** Workflow engine for orchestrating agent execution */
|
|
||||||
export class WorkflowEngine extends EventEmitter {
|
|
||||||
private state: WorkflowState;
|
|
||||||
private config: WorkflowConfig;
|
|
||||||
private projectCwd: string;
|
|
||||||
private cwd: string;
|
|
||||||
private task: string;
|
|
||||||
private options: WorkflowEngineOptions;
|
|
||||||
private loopDetector: LoopDetector;
|
|
||||||
private language: WorkflowEngineOptions['language'];
|
|
||||||
private reportDir: string;
|
|
||||||
private abortRequested = false;
|
|
||||||
|
|
||||||
constructor(config: WorkflowConfig, cwd: string, task: string, options: WorkflowEngineOptions) {
|
|
||||||
super();
|
|
||||||
this.config = config;
|
|
||||||
this.projectCwd = options.projectCwd;
|
|
||||||
this.cwd = cwd;
|
|
||||||
this.task = task;
|
|
||||||
this.options = options;
|
|
||||||
this.language = options.language;
|
|
||||||
this.loopDetector = new LoopDetector(config.loopDetection);
|
|
||||||
this.reportDir = `.takt/reports/${generateReportDir(task)}`;
|
|
||||||
this.ensureReportDirExists();
|
|
||||||
this.validateConfig();
|
|
||||||
this.state = createInitialState(config, options);
|
|
||||||
log.debug('WorkflowEngine initialized', {
|
|
||||||
workflow: config.name,
|
|
||||||
steps: config.steps.map(s => s.name),
|
|
||||||
initialStep: config.initialStep,
|
|
||||||
maxIterations: config.maxIterations,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Ensure report directory exists (always in project root, not clone) */
|
|
||||||
private ensureReportDirExists(): void {
|
|
||||||
const reportDirPath = join(this.projectCwd, this.reportDir);
|
|
||||||
if (!existsSync(reportDirPath)) {
|
|
||||||
mkdirSync(reportDirPath, { recursive: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Worktree mode: create symlink so agents can access reports via relative path
|
|
||||||
if (this.cwd !== this.projectCwd) {
|
|
||||||
const cwdReportsDir = join(this.cwd, '.takt', 'reports');
|
|
||||||
if (!existsSync(cwdReportsDir)) {
|
|
||||||
mkdirSync(join(this.cwd, '.takt'), { recursive: true });
|
|
||||||
symlinkSync(
|
|
||||||
join(this.projectCwd, '.takt', 'reports'),
|
|
||||||
cwdReportsDir,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Validate workflow configuration at construction time */
|
|
||||||
private validateConfig(): void {
|
|
||||||
const initialStep = this.config.steps.find((s) => s.name === this.config.initialStep);
|
|
||||||
if (!initialStep) {
|
|
||||||
throw new Error(ERROR_MESSAGES.UNKNOWN_STEP(this.config.initialStep));
|
|
||||||
}
|
|
||||||
|
|
||||||
const stepNames = new Set(this.config.steps.map((s) => s.name));
|
|
||||||
stepNames.add(COMPLETE_STEP);
|
|
||||||
stepNames.add(ABORT_STEP);
|
|
||||||
|
|
||||||
for (const step of this.config.steps) {
|
|
||||||
if (step.rules) {
|
|
||||||
for (const rule of step.rules) {
|
|
||||||
if (rule.next && !stepNames.has(rule.next)) {
|
|
||||||
throw new Error(
|
|
||||||
`Invalid rule in step "${step.name}": target step "${rule.next}" does not exist`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Get current workflow state */
|
|
||||||
getState(): WorkflowState {
|
|
||||||
return { ...this.state };
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Add user input */
|
|
||||||
addUserInput(input: string): void {
|
|
||||||
addUserInput(this.state, input);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Update working directory */
|
|
||||||
updateCwd(newCwd: string): void {
|
|
||||||
this.cwd = newCwd;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Get current working directory */
|
|
||||||
getCwd(): string {
|
|
||||||
return this.cwd;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Get project root directory (where .takt/ lives) */
|
|
||||||
getProjectCwd(): string {
|
|
||||||
return this.projectCwd;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Request graceful abort: interrupt running queries and stop after current step */
|
|
||||||
abort(): void {
|
|
||||||
if (this.abortRequested) return;
|
|
||||||
this.abortRequested = true;
|
|
||||||
log.info('Abort requested');
|
|
||||||
interruptAllQueries();
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Check if abort has been requested */
|
|
||||||
isAbortRequested(): boolean {
|
|
||||||
return this.abortRequested;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Build instruction from template */
|
|
||||||
private buildInstruction(step: WorkflowStep, stepIteration: number): string {
|
|
||||||
return buildInstructionFromTemplate(step, {
|
|
||||||
task: this.task,
|
|
||||||
iteration: this.state.iteration,
|
|
||||||
maxIterations: this.config.maxIterations,
|
|
||||||
stepIteration,
|
|
||||||
cwd: this.cwd,
|
|
||||||
projectCwd: this.projectCwd,
|
|
||||||
userInputs: this.state.userInputs,
|
|
||||||
previousOutput: getPreviousOutput(this.state),
|
|
||||||
reportDir: join(this.cwd, this.reportDir),
|
|
||||||
language: this.language,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Get step by name */
|
|
||||||
private getStep(name: string): WorkflowStep {
|
|
||||||
const step = this.config.steps.find((s) => s.name === name);
|
|
||||||
if (!step) {
|
|
||||||
throw new Error(ERROR_MESSAGES.UNKNOWN_STEP(name));
|
|
||||||
}
|
|
||||||
return step;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Emit step:report events for each report file that exists after step completion.
|
|
||||||
* The UI layer (workflowExecution.ts) listens and displays the content.
|
|
||||||
*/
|
|
||||||
private emitStepReports(step: WorkflowStep): void {
|
|
||||||
if (!step.report || !this.reportDir) return;
|
|
||||||
const baseDir = join(this.projectCwd, this.reportDir);
|
|
||||||
|
|
||||||
if (typeof step.report === 'string') {
|
|
||||||
this.emitIfReportExists(step, baseDir, step.report);
|
|
||||||
} else if (isReportObjectConfig(step.report)) {
|
|
||||||
this.emitIfReportExists(step, baseDir, step.report.name);
|
|
||||||
} else {
|
|
||||||
// ReportConfig[] (array)
|
|
||||||
for (const rc of step.report) {
|
|
||||||
this.emitIfReportExists(step, baseDir, rc.path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Emit step:report if the report file exists */
|
|
||||||
private emitIfReportExists(step: WorkflowStep, baseDir: string, fileName: string): void {
|
|
||||||
const filePath = join(baseDir, fileName);
|
|
||||||
if (existsSync(filePath)) {
|
|
||||||
this.emit('step:report', step, filePath, fileName);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Run a single step (delegates to runParallelStep if step has parallel sub-steps) */
|
|
||||||
private async runStep(step: WorkflowStep, prebuiltInstruction?: string): Promise<{ response: AgentResponse; instruction: string }> {
|
|
||||||
if (step.parallel && step.parallel.length > 0) {
|
|
||||||
return this.runParallelStep(step);
|
|
||||||
}
|
|
||||||
return this.runNormalStep(step, prebuiltInstruction);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Build common RunAgentOptions shared by all phases */
|
|
||||||
private buildBaseOptions(step: WorkflowStep): RunAgentOptions {
|
|
||||||
return {
|
|
||||||
cwd: this.cwd,
|
|
||||||
agentPath: step.agentPath,
|
|
||||||
provider: step.provider ?? this.options.provider,
|
|
||||||
model: step.model ?? this.options.model,
|
|
||||||
permissionMode: step.permissionMode,
|
|
||||||
onStream: this.options.onStream,
|
|
||||||
onPermissionRequest: this.options.onPermissionRequest,
|
|
||||||
onAskUserQuestion: this.options.onAskUserQuestion,
|
|
||||||
bypassPermissions: this.options.bypassPermissions,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Build RunAgentOptions from a step's configuration (Phase 1) */
|
|
||||||
private buildAgentOptions(step: WorkflowStep): RunAgentOptions {
|
|
||||||
// Phase 1: exclude Write from allowedTools when step has report config
|
|
||||||
const allowedTools = step.report
|
|
||||||
? step.allowedTools?.filter((t) => t !== 'Write')
|
|
||||||
: step.allowedTools;
|
|
||||||
|
|
||||||
return {
|
|
||||||
...this.buildBaseOptions(step),
|
|
||||||
sessionId: this.state.agentSessions.get(step.agent),
|
|
||||||
allowedTools,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Build RunAgentOptions for session-resume phases (Phase 2, Phase 3).
|
|
||||||
*/
|
|
||||||
private buildResumeOptions(step: WorkflowStep, sessionId: string, overrides: Pick<RunAgentOptions, 'allowedTools' | 'maxTurns'>): RunAgentOptions {
|
|
||||||
return {
|
|
||||||
...this.buildBaseOptions(step),
|
|
||||||
sessionId,
|
|
||||||
allowedTools: overrides.allowedTools,
|
|
||||||
maxTurns: overrides.maxTurns,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Update agent session and notify via callback if session changed */
|
|
||||||
private updateAgentSession(agent: string, sessionId: string | undefined): void {
|
|
||||||
if (!sessionId) return;
|
|
||||||
|
|
||||||
const previousSessionId = this.state.agentSessions.get(agent);
|
|
||||||
this.state.agentSessions.set(agent, sessionId);
|
|
||||||
|
|
||||||
if (this.options.onSessionUpdate && sessionId !== previousSessionId) {
|
|
||||||
this.options.onSessionUpdate(agent, sessionId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Build phase runner context for Phase 2/3 execution */
|
|
||||||
private buildPhaseRunnerContext() {
|
|
||||||
return {
|
|
||||||
cwd: this.cwd,
|
|
||||||
reportDir: join(this.cwd, this.reportDir),
|
|
||||||
language: this.language,
|
|
||||||
getSessionId: (agent: string) => this.state.agentSessions.get(agent),
|
|
||||||
buildResumeOptions: this.buildResumeOptions.bind(this),
|
|
||||||
updateAgentSession: this.updateAgentSession.bind(this),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Run a normal (non-parallel) step */
|
|
||||||
private async runNormalStep(step: WorkflowStep, prebuiltInstruction?: string): Promise<{ response: AgentResponse; instruction: string }> {
|
|
||||||
const stepIteration = prebuiltInstruction
|
|
||||||
? this.state.stepIterations.get(step.name) ?? 1
|
|
||||||
: incrementStepIteration(this.state, step.name);
|
|
||||||
const instruction = prebuiltInstruction ?? this.buildInstruction(step, stepIteration);
|
|
||||||
log.debug('Running step', {
|
|
||||||
step: step.name,
|
|
||||||
agent: step.agent,
|
|
||||||
stepIteration,
|
|
||||||
iteration: this.state.iteration,
|
|
||||||
sessionId: this.state.agentSessions.get(step.agent) ?? 'new',
|
|
||||||
});
|
|
||||||
|
|
||||||
// Phase 1: main execution (Write excluded if step has report)
|
|
||||||
const agentOptions = this.buildAgentOptions(step);
|
|
||||||
let response = await runAgent(step.agent, instruction, agentOptions);
|
|
||||||
this.updateAgentSession(step.agent, response.sessionId);
|
|
||||||
|
|
||||||
const phaseCtx = this.buildPhaseRunnerContext();
|
|
||||||
|
|
||||||
// Phase 2: report output (resume same session, Write only)
|
|
||||||
if (step.report) {
|
|
||||||
await runReportPhase(step, stepIteration, phaseCtx);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Phase 3: status judgment (resume session, no tools, output status tag)
|
|
||||||
let tagContent = '';
|
|
||||||
if (needsStatusJudgmentPhase(step)) {
|
|
||||||
tagContent = await runStatusJudgmentPhase(step, phaseCtx);
|
|
||||||
}
|
|
||||||
|
|
||||||
const match = await detectMatchedRule(step, response.content, tagContent, {
|
|
||||||
state: this.state,
|
|
||||||
cwd: this.cwd,
|
|
||||||
});
|
|
||||||
if (match) {
|
|
||||||
log.debug('Rule matched', { step: step.name, ruleIndex: match.index, method: match.method });
|
|
||||||
response = { ...response, matchedRuleIndex: match.index, matchedRuleMethod: match.method };
|
|
||||||
}
|
|
||||||
|
|
||||||
this.state.stepOutputs.set(step.name, response);
|
|
||||||
this.emitStepReports(step);
|
|
||||||
return { response, instruction };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Run a parallel step: execute all sub-steps concurrently, then aggregate results.
|
|
||||||
* The aggregated output becomes the parent step's response for rules evaluation.
|
|
||||||
*
|
|
||||||
* When onStream is provided, uses ParallelLogger to prefix each sub-step's
|
|
||||||
* output with `[name]` for readable interleaved display.
|
|
||||||
*/
|
|
||||||
private async runParallelStep(step: WorkflowStep): Promise<{ response: AgentResponse; instruction: string }> {
|
|
||||||
const subSteps = step.parallel!;
|
|
||||||
const stepIteration = incrementStepIteration(this.state, step.name);
|
|
||||||
log.debug('Running parallel step', {
|
|
||||||
step: step.name,
|
|
||||||
subSteps: subSteps.map(s => s.name),
|
|
||||||
stepIteration,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Create parallel logger for prefixed output (only when streaming is enabled)
|
|
||||||
const parallelLogger = this.options.onStream
|
|
||||||
? new ParallelLogger({
|
|
||||||
subStepNames: subSteps.map((s) => s.name),
|
|
||||||
parentOnStream: this.options.onStream,
|
|
||||||
})
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
const phaseCtx = this.buildPhaseRunnerContext();
|
|
||||||
const ruleCtx = { state: this.state, cwd: this.cwd };
|
|
||||||
|
|
||||||
// Run all sub-steps concurrently
|
|
||||||
const subResults = await Promise.all(
|
|
||||||
subSteps.map(async (subStep, index) => {
|
|
||||||
const subIteration = incrementStepIteration(this.state, subStep.name);
|
|
||||||
const subInstruction = this.buildInstruction(subStep, subIteration);
|
|
||||||
|
|
||||||
// Phase 1: main execution (Write excluded if sub-step has report)
|
|
||||||
const baseOptions = this.buildAgentOptions(subStep);
|
|
||||||
|
|
||||||
// Override onStream with parallel logger's prefixed handler (immutable)
|
|
||||||
const agentOptions = parallelLogger
|
|
||||||
? { ...baseOptions, onStream: parallelLogger.createStreamHandler(subStep.name, index) }
|
|
||||||
: baseOptions;
|
|
||||||
|
|
||||||
const subResponse = await runAgent(subStep.agent, subInstruction, agentOptions);
|
|
||||||
this.updateAgentSession(subStep.agent, subResponse.sessionId);
|
|
||||||
|
|
||||||
// Phase 2: report output for sub-step
|
|
||||||
if (subStep.report) {
|
|
||||||
await runReportPhase(subStep, subIteration, phaseCtx);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Phase 3: status judgment for sub-step
|
|
||||||
let subTagContent = '';
|
|
||||||
if (needsStatusJudgmentPhase(subStep)) {
|
|
||||||
subTagContent = await runStatusJudgmentPhase(subStep, phaseCtx);
|
|
||||||
}
|
|
||||||
|
|
||||||
const match = await detectMatchedRule(subStep, subResponse.content, subTagContent, ruleCtx);
|
|
||||||
const finalResponse = match
|
|
||||||
? { ...subResponse, matchedRuleIndex: match.index, matchedRuleMethod: match.method }
|
|
||||||
: subResponse;
|
|
||||||
|
|
||||||
this.state.stepOutputs.set(subStep.name, finalResponse);
|
|
||||||
this.emitStepReports(subStep);
|
|
||||||
|
|
||||||
return { subStep, response: finalResponse, instruction: subInstruction };
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
// Print completion summary
|
|
||||||
if (parallelLogger) {
|
|
||||||
parallelLogger.printSummary(
|
|
||||||
step.name,
|
|
||||||
subResults.map((r) => ({
|
|
||||||
name: r.subStep.name,
|
|
||||||
condition: r.response.matchedRuleIndex != null && r.subStep.rules
|
|
||||||
? r.subStep.rules[r.response.matchedRuleIndex]?.condition
|
|
||||||
: undefined,
|
|
||||||
})),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Aggregate sub-step outputs into parent step's response
|
|
||||||
const aggregatedContent = subResults
|
|
||||||
.map((r) => `## ${r.subStep.name}\n${r.response.content}`)
|
|
||||||
.join('\n\n---\n\n');
|
|
||||||
|
|
||||||
const aggregatedInstruction = subResults
|
|
||||||
.map((r) => r.instruction)
|
|
||||||
.join('\n\n');
|
|
||||||
|
|
||||||
// Parent step uses aggregate conditions, so tagContent is empty
|
|
||||||
const match = await detectMatchedRule(step, aggregatedContent, '', ruleCtx);
|
|
||||||
|
|
||||||
const aggregatedResponse: AgentResponse = {
|
|
||||||
agent: step.name,
|
|
||||||
status: 'done',
|
|
||||||
content: aggregatedContent,
|
|
||||||
timestamp: new Date(),
|
|
||||||
...(match && { matchedRuleIndex: match.index, matchedRuleMethod: match.method }),
|
|
||||||
};
|
|
||||||
|
|
||||||
this.state.stepOutputs.set(step.name, aggregatedResponse);
|
|
||||||
this.emitStepReports(step);
|
|
||||||
return { response: aggregatedResponse, instruction: aggregatedInstruction };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Determine next step for a completed step using rules-based routing.
|
|
||||||
*/
|
|
||||||
private resolveNextStep(step: WorkflowStep, response: AgentResponse): string {
|
|
||||||
if (response.matchedRuleIndex != null && step.rules) {
|
|
||||||
const nextByRules = determineNextStepByRules(step, response.matchedRuleIndex);
|
|
||||||
if (nextByRules) {
|
|
||||||
return nextByRules;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error(`No matching rule found for step "${step.name}" (status: ${response.status})`);
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Run the workflow to completion */
|
|
||||||
async run(): Promise<WorkflowState> {
|
|
||||||
while (this.state.status === 'running') {
|
|
||||||
if (this.abortRequested) {
|
|
||||||
this.state.status = 'aborted';
|
|
||||||
this.emit('workflow:abort', this.state, 'Workflow interrupted by user (SIGINT)');
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.state.iteration >= this.config.maxIterations) {
|
|
||||||
this.emit('iteration:limit', this.state.iteration, this.config.maxIterations);
|
|
||||||
|
|
||||||
if (this.options.onIterationLimit) {
|
|
||||||
const additionalIterations = await this.options.onIterationLimit({
|
|
||||||
currentIteration: this.state.iteration,
|
|
||||||
maxIterations: this.config.maxIterations,
|
|
||||||
currentStep: this.state.currentStep,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (additionalIterations !== null && additionalIterations > 0) {
|
|
||||||
this.config = {
|
|
||||||
...this.config,
|
|
||||||
maxIterations: this.config.maxIterations + additionalIterations,
|
|
||||||
};
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.state.status = 'aborted';
|
|
||||||
this.emit('workflow:abort', this.state, ERROR_MESSAGES.MAX_ITERATIONS_REACHED);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
const step = this.getStep(this.state.currentStep);
|
|
||||||
const loopCheck = this.loopDetector.check(step.name);
|
|
||||||
|
|
||||||
if (loopCheck.shouldWarn) {
|
|
||||||
this.emit('step:loop_detected', step, loopCheck.count);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (loopCheck.shouldAbort) {
|
|
||||||
this.state.status = 'aborted';
|
|
||||||
this.emit('workflow:abort', this.state, ERROR_MESSAGES.LOOP_DETECTED(step.name, loopCheck.count));
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.state.iteration++;
|
|
||||||
|
|
||||||
// Build instruction before emitting step:start so listeners can log it
|
|
||||||
const isParallel = step.parallel && step.parallel.length > 0;
|
|
||||||
let prebuiltInstruction: string | undefined;
|
|
||||||
if (!isParallel) {
|
|
||||||
const stepIteration = incrementStepIteration(this.state, step.name);
|
|
||||||
prebuiltInstruction = this.buildInstruction(step, stepIteration);
|
|
||||||
}
|
|
||||||
this.emit('step:start', step, this.state.iteration, prebuiltInstruction ?? '');
|
|
||||||
|
|
||||||
try {
|
|
||||||
const { response, instruction } = await this.runStep(step, prebuiltInstruction);
|
|
||||||
this.emit('step:complete', step, response, instruction);
|
|
||||||
|
|
||||||
if (response.status === 'blocked') {
|
|
||||||
this.emit('step:blocked', step, response);
|
|
||||||
const result = await handleBlocked(step, response, this.options);
|
|
||||||
|
|
||||||
if (result.shouldContinue && result.userInput) {
|
|
||||||
this.addUserInput(result.userInput);
|
|
||||||
this.emit('step:user_input', step, result.userInput);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.state.status = 'aborted';
|
|
||||||
this.emit('workflow:abort', this.state, 'Workflow blocked and no user input provided');
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextStep = this.resolveNextStep(step, response);
|
|
||||||
log.debug('Step transition', {
|
|
||||||
from: step.name,
|
|
||||||
status: response.status,
|
|
||||||
matchedRuleIndex: response.matchedRuleIndex,
|
|
||||||
nextStep,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (nextStep === COMPLETE_STEP) {
|
|
||||||
this.state.status = 'completed';
|
|
||||||
this.emit('workflow:complete', this.state);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (nextStep === ABORT_STEP) {
|
|
||||||
this.state.status = 'aborted';
|
|
||||||
this.emit('workflow:abort', this.state, 'Workflow aborted by step transition');
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.state.currentStep = nextStep;
|
|
||||||
} catch (error) {
|
|
||||||
this.state.status = 'aborted';
|
|
||||||
if (this.abortRequested) {
|
|
||||||
this.emit('workflow:abort', this.state, 'Workflow interrupted by user (SIGINT)');
|
|
||||||
} else {
|
|
||||||
const message = getErrorMessage(error);
|
|
||||||
this.emit('workflow:abort', this.state, ERROR_MESSAGES.STEP_EXECUTION_FAILED(message));
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.state;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Run a single iteration (for interactive mode) */
|
|
||||||
async runSingleIteration(): Promise<{
|
|
||||||
response: AgentResponse;
|
|
||||||
nextStep: string;
|
|
||||||
isComplete: boolean;
|
|
||||||
loopDetected?: boolean;
|
|
||||||
}> {
|
|
||||||
const step = this.getStep(this.state.currentStep);
|
|
||||||
const loopCheck = this.loopDetector.check(step.name);
|
|
||||||
|
|
||||||
if (loopCheck.shouldAbort) {
|
|
||||||
this.state.status = 'aborted';
|
|
||||||
return {
|
|
||||||
response: {
|
|
||||||
agent: step.agent,
|
|
||||||
status: 'blocked',
|
|
||||||
content: ERROR_MESSAGES.LOOP_DETECTED(step.name, loopCheck.count),
|
|
||||||
timestamp: new Date(),
|
|
||||||
},
|
|
||||||
nextStep: ABORT_STEP,
|
|
||||||
isComplete: true,
|
|
||||||
loopDetected: true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
this.state.iteration++;
|
|
||||||
const { response } = await this.runStep(step);
|
|
||||||
const nextStep = this.resolveNextStep(step, response);
|
|
||||||
const isComplete = nextStep === COMPLETE_STEP || nextStep === ABORT_STEP;
|
|
||||||
|
|
||||||
if (!isComplete) {
|
|
||||||
this.state.currentStep = nextStep;
|
|
||||||
} else {
|
|
||||||
this.state.status = nextStep === COMPLETE_STEP ? 'completed' : 'aborted';
|
|
||||||
}
|
|
||||||
|
|
||||||
return { response, nextStep, isComplete, loopDetected: loopCheck.isLoop };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
80
src/workflow/engine/OptionsBuilder.ts
Normal file
80
src/workflow/engine/OptionsBuilder.ts
Normal file
@ -0,0 +1,80 @@
|
|||||||
|
/**
|
||||||
|
* Builds RunAgentOptions for different execution phases.
|
||||||
|
*
|
||||||
|
* Centralizes the option construction logic that was previously
|
||||||
|
* scattered across WorkflowEngine methods.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import type { WorkflowStep, WorkflowState, Language } from '../../models/types.js';
|
||||||
|
import type { RunAgentOptions } from '../../agents/runner.js';
|
||||||
|
import type { PhaseRunnerContext } from '../phase-runner.js';
|
||||||
|
import type { WorkflowEngineOptions } from '../types.js';
|
||||||
|
|
||||||
|
export class OptionsBuilder {
|
||||||
|
constructor(
|
||||||
|
private readonly engineOptions: WorkflowEngineOptions,
|
||||||
|
private readonly getCwd: () => string,
|
||||||
|
private readonly getSessionId: (agent: string) => string | undefined,
|
||||||
|
private readonly getReportDir: () => string,
|
||||||
|
private readonly getLanguage: () => Language | undefined,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** Build common RunAgentOptions shared by all phases */
|
||||||
|
buildBaseOptions(step: WorkflowStep): RunAgentOptions {
|
||||||
|
return {
|
||||||
|
cwd: this.getCwd(),
|
||||||
|
agentPath: step.agentPath,
|
||||||
|
provider: step.provider ?? this.engineOptions.provider,
|
||||||
|
model: step.model ?? this.engineOptions.model,
|
||||||
|
permissionMode: step.permissionMode,
|
||||||
|
onStream: this.engineOptions.onStream,
|
||||||
|
onPermissionRequest: this.engineOptions.onPermissionRequest,
|
||||||
|
onAskUserQuestion: this.engineOptions.onAskUserQuestion,
|
||||||
|
bypassPermissions: this.engineOptions.bypassPermissions,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build RunAgentOptions for Phase 1 (main execution) */
|
||||||
|
buildAgentOptions(step: WorkflowStep): RunAgentOptions {
|
||||||
|
// Phase 1: exclude Write from allowedTools when step has report config
|
||||||
|
const allowedTools = step.report
|
||||||
|
? step.allowedTools?.filter((t) => t !== 'Write')
|
||||||
|
: step.allowedTools;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...this.buildBaseOptions(step),
|
||||||
|
sessionId: this.getSessionId(step.agent),
|
||||||
|
allowedTools,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build RunAgentOptions for session-resume phases (Phase 2, Phase 3) */
|
||||||
|
buildResumeOptions(
|
||||||
|
step: WorkflowStep,
|
||||||
|
sessionId: string,
|
||||||
|
overrides: Pick<RunAgentOptions, 'allowedTools' | 'maxTurns'>,
|
||||||
|
): RunAgentOptions {
|
||||||
|
return {
|
||||||
|
...this.buildBaseOptions(step),
|
||||||
|
sessionId,
|
||||||
|
allowedTools: overrides.allowedTools,
|
||||||
|
maxTurns: overrides.maxTurns,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build PhaseRunnerContext for Phase 2/3 execution */
|
||||||
|
buildPhaseRunnerContext(
|
||||||
|
state: WorkflowState,
|
||||||
|
updateAgentSession: (agent: string, sessionId: string | undefined) => void,
|
||||||
|
): PhaseRunnerContext {
|
||||||
|
return {
|
||||||
|
cwd: this.getCwd(),
|
||||||
|
reportDir: join(this.getCwd(), this.getReportDir()),
|
||||||
|
language: this.getLanguage(),
|
||||||
|
getSessionId: (agent: string) => state.agentSessions.get(agent),
|
||||||
|
buildResumeOptions: this.buildResumeOptions.bind(this),
|
||||||
|
updateAgentSession,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
146
src/workflow/engine/ParallelRunner.ts
Normal file
146
src/workflow/engine/ParallelRunner.ts
Normal file
@ -0,0 +1,146 @@
|
|||||||
|
/**
|
||||||
|
* Executes parallel workflow steps concurrently and aggregates results.
|
||||||
|
*
|
||||||
|
* When onStream is provided, uses ParallelLogger to prefix each
|
||||||
|
* sub-step's output with `[name]` for readable interleaved display.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import type {
|
||||||
|
WorkflowStep,
|
||||||
|
WorkflowState,
|
||||||
|
AgentResponse,
|
||||||
|
} from '../../models/types.js';
|
||||||
|
import { runAgent } from '../../agents/runner.js';
|
||||||
|
import { ParallelLogger } from '../parallel-logger.js';
|
||||||
|
import { needsStatusJudgmentPhase, runReportPhase, runStatusJudgmentPhase } from '../phase-runner.js';
|
||||||
|
import { detectMatchedRule } from '../rule-evaluator.js';
|
||||||
|
import { incrementStepIteration } from '../state-manager.js';
|
||||||
|
import { createLogger } from '../../utils/debug.js';
|
||||||
|
import type { OptionsBuilder } from './OptionsBuilder.js';
|
||||||
|
import type { StepExecutor } from './StepExecutor.js';
|
||||||
|
import type { WorkflowEngineOptions } from '../types.js';
|
||||||
|
|
||||||
|
const log = createLogger('parallel-runner');
|
||||||
|
|
||||||
|
export interface ParallelRunnerDeps {
|
||||||
|
readonly optionsBuilder: OptionsBuilder;
|
||||||
|
readonly stepExecutor: StepExecutor;
|
||||||
|
readonly engineOptions: WorkflowEngineOptions;
|
||||||
|
readonly getCwd: () => string;
|
||||||
|
readonly getReportDir: () => string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ParallelRunner {
|
||||||
|
constructor(
|
||||||
|
private readonly deps: ParallelRunnerDeps,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run a parallel step: execute all sub-steps concurrently, then aggregate results.
|
||||||
|
* The aggregated output becomes the parent step's response for rules evaluation.
|
||||||
|
*/
|
||||||
|
async runParallelStep(
|
||||||
|
step: WorkflowStep,
|
||||||
|
state: WorkflowState,
|
||||||
|
task: string,
|
||||||
|
maxIterations: number,
|
||||||
|
updateAgentSession: (agent: string, sessionId: string | undefined) => void,
|
||||||
|
): Promise<{ response: AgentResponse; instruction: string }> {
|
||||||
|
const subSteps = step.parallel!;
|
||||||
|
const stepIteration = incrementStepIteration(state, step.name);
|
||||||
|
log.debug('Running parallel step', {
|
||||||
|
step: step.name,
|
||||||
|
subSteps: subSteps.map(s => s.name),
|
||||||
|
stepIteration,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Create parallel logger for prefixed output (only when streaming is enabled)
|
||||||
|
const parallelLogger = this.deps.engineOptions.onStream
|
||||||
|
? new ParallelLogger({
|
||||||
|
subStepNames: subSteps.map((s) => s.name),
|
||||||
|
parentOnStream: this.deps.engineOptions.onStream,
|
||||||
|
})
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const phaseCtx = this.deps.optionsBuilder.buildPhaseRunnerContext(state, updateAgentSession);
|
||||||
|
const ruleCtx = { state, cwd: this.deps.getCwd() };
|
||||||
|
|
||||||
|
// Run all sub-steps concurrently
|
||||||
|
const subResults = await Promise.all(
|
||||||
|
subSteps.map(async (subStep, index) => {
|
||||||
|
const subIteration = incrementStepIteration(state, subStep.name);
|
||||||
|
const subInstruction = this.deps.stepExecutor.buildInstruction(subStep, subIteration, state, task, maxIterations);
|
||||||
|
|
||||||
|
// Phase 1: main execution (Write excluded if sub-step has report)
|
||||||
|
const baseOptions = this.deps.optionsBuilder.buildAgentOptions(subStep);
|
||||||
|
|
||||||
|
// Override onStream with parallel logger's prefixed handler (immutable)
|
||||||
|
const agentOptions = parallelLogger
|
||||||
|
? { ...baseOptions, onStream: parallelLogger.createStreamHandler(subStep.name, index) }
|
||||||
|
: baseOptions;
|
||||||
|
|
||||||
|
const subResponse = await runAgent(subStep.agent, subInstruction, agentOptions);
|
||||||
|
updateAgentSession(subStep.agent, subResponse.sessionId);
|
||||||
|
|
||||||
|
// Phase 2: report output for sub-step
|
||||||
|
if (subStep.report) {
|
||||||
|
await runReportPhase(subStep, subIteration, phaseCtx);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 3: status judgment for sub-step
|
||||||
|
let subTagContent = '';
|
||||||
|
if (needsStatusJudgmentPhase(subStep)) {
|
||||||
|
subTagContent = await runStatusJudgmentPhase(subStep, phaseCtx);
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = await detectMatchedRule(subStep, subResponse.content, subTagContent, ruleCtx);
|
||||||
|
const finalResponse = match
|
||||||
|
? { ...subResponse, matchedRuleIndex: match.index, matchedRuleMethod: match.method }
|
||||||
|
: subResponse;
|
||||||
|
|
||||||
|
state.stepOutputs.set(subStep.name, finalResponse);
|
||||||
|
this.deps.stepExecutor.emitStepReports(subStep);
|
||||||
|
|
||||||
|
return { subStep, response: finalResponse, instruction: subInstruction };
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Print completion summary
|
||||||
|
if (parallelLogger) {
|
||||||
|
parallelLogger.printSummary(
|
||||||
|
step.name,
|
||||||
|
subResults.map((r) => ({
|
||||||
|
name: r.subStep.name,
|
||||||
|
condition: r.response.matchedRuleIndex != null && r.subStep.rules
|
||||||
|
? r.subStep.rules[r.response.matchedRuleIndex]?.condition
|
||||||
|
: undefined,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Aggregate sub-step outputs into parent step's response
|
||||||
|
const aggregatedContent = subResults
|
||||||
|
.map((r) => `## ${r.subStep.name}\n${r.response.content}`)
|
||||||
|
.join('\n\n---\n\n');
|
||||||
|
|
||||||
|
const aggregatedInstruction = subResults
|
||||||
|
.map((r) => r.instruction)
|
||||||
|
.join('\n\n');
|
||||||
|
|
||||||
|
// Parent step uses aggregate conditions, so tagContent is empty
|
||||||
|
const match = await detectMatchedRule(step, aggregatedContent, '', ruleCtx);
|
||||||
|
|
||||||
|
const aggregatedResponse: AgentResponse = {
|
||||||
|
agent: step.name,
|
||||||
|
status: 'done',
|
||||||
|
content: aggregatedContent,
|
||||||
|
timestamp: new Date(),
|
||||||
|
...(match && { matchedRuleIndex: match.index, matchedRuleMethod: match.method }),
|
||||||
|
};
|
||||||
|
|
||||||
|
state.stepOutputs.set(step.name, aggregatedResponse);
|
||||||
|
this.deps.stepExecutor.emitStepReports(step);
|
||||||
|
return { response: aggregatedResponse, instruction: aggregatedInstruction };
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
155
src/workflow/engine/StepExecutor.ts
Normal file
155
src/workflow/engine/StepExecutor.ts
Normal file
@ -0,0 +1,155 @@
|
|||||||
|
/**
|
||||||
|
* Executes a single workflow step through the 3-phase model.
|
||||||
|
*
|
||||||
|
* Phase 1: Main agent execution (with tools)
|
||||||
|
* Phase 2: Report output (Write-only, optional)
|
||||||
|
* Phase 3: Status judgment (no tools, optional)
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { existsSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import type {
|
||||||
|
WorkflowStep,
|
||||||
|
WorkflowState,
|
||||||
|
AgentResponse,
|
||||||
|
Language,
|
||||||
|
} from '../../models/types.js';
|
||||||
|
import { runAgent } from '../../agents/runner.js';
|
||||||
|
import { buildInstruction as buildInstructionFromTemplate, isReportObjectConfig } from '../instruction-builder.js';
|
||||||
|
import { needsStatusJudgmentPhase, runReportPhase, runStatusJudgmentPhase } from '../phase-runner.js';
|
||||||
|
import { detectMatchedRule } from '../rule-evaluator.js';
|
||||||
|
import { incrementStepIteration, getPreviousOutput } from '../state-manager.js';
|
||||||
|
import { createLogger } from '../../utils/debug.js';
|
||||||
|
import type { OptionsBuilder } from './OptionsBuilder.js';
|
||||||
|
|
||||||
|
const log = createLogger('step-executor');
|
||||||
|
|
||||||
|
export interface StepExecutorDeps {
|
||||||
|
readonly optionsBuilder: OptionsBuilder;
|
||||||
|
readonly getCwd: () => string;
|
||||||
|
readonly getProjectCwd: () => string;
|
||||||
|
readonly getReportDir: () => string;
|
||||||
|
readonly getLanguage: () => Language | undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class StepExecutor {
|
||||||
|
constructor(
|
||||||
|
private readonly deps: StepExecutorDeps,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** Build Phase 1 instruction from template */
|
||||||
|
buildInstruction(
|
||||||
|
step: WorkflowStep,
|
||||||
|
stepIteration: number,
|
||||||
|
state: WorkflowState,
|
||||||
|
task: string,
|
||||||
|
maxIterations: number,
|
||||||
|
): string {
|
||||||
|
return buildInstructionFromTemplate(step, {
|
||||||
|
task,
|
||||||
|
iteration: state.iteration,
|
||||||
|
maxIterations,
|
||||||
|
stepIteration,
|
||||||
|
cwd: this.deps.getCwd(),
|
||||||
|
projectCwd: this.deps.getProjectCwd(),
|
||||||
|
userInputs: state.userInputs,
|
||||||
|
previousOutput: getPreviousOutput(state),
|
||||||
|
reportDir: join(this.deps.getCwd(), this.deps.getReportDir()),
|
||||||
|
language: this.deps.getLanguage(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Execute a normal (non-parallel) step through all 3 phases.
|
||||||
|
*
|
||||||
|
* Returns the final response (with matchedRuleIndex if a rule matched)
|
||||||
|
* and the instruction used for Phase 1.
|
||||||
|
*/
|
||||||
|
async runNormalStep(
|
||||||
|
step: WorkflowStep,
|
||||||
|
state: WorkflowState,
|
||||||
|
task: string,
|
||||||
|
maxIterations: number,
|
||||||
|
updateAgentSession: (agent: string, sessionId: string | undefined) => void,
|
||||||
|
prebuiltInstruction?: string,
|
||||||
|
): Promise<{ response: AgentResponse; instruction: string }> {
|
||||||
|
const stepIteration = prebuiltInstruction
|
||||||
|
? state.stepIterations.get(step.name) ?? 1
|
||||||
|
: incrementStepIteration(state, step.name);
|
||||||
|
const instruction = prebuiltInstruction ?? this.buildInstruction(step, stepIteration, state, task, maxIterations);
|
||||||
|
log.debug('Running step', {
|
||||||
|
step: step.name,
|
||||||
|
agent: step.agent,
|
||||||
|
stepIteration,
|
||||||
|
iteration: state.iteration,
|
||||||
|
sessionId: state.agentSessions.get(step.agent) ?? 'new',
|
||||||
|
});
|
||||||
|
|
||||||
|
// Phase 1: main execution (Write excluded if step has report)
|
||||||
|
const agentOptions = this.deps.optionsBuilder.buildAgentOptions(step);
|
||||||
|
let response = await runAgent(step.agent, instruction, agentOptions);
|
||||||
|
updateAgentSession(step.agent, response.sessionId);
|
||||||
|
|
||||||
|
const phaseCtx = this.deps.optionsBuilder.buildPhaseRunnerContext(state, updateAgentSession);
|
||||||
|
|
||||||
|
// Phase 2: report output (resume same session, Write only)
|
||||||
|
if (step.report) {
|
||||||
|
await runReportPhase(step, stepIteration, phaseCtx);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Phase 3: status judgment (resume session, no tools, output status tag)
|
||||||
|
let tagContent = '';
|
||||||
|
if (needsStatusJudgmentPhase(step)) {
|
||||||
|
tagContent = await runStatusJudgmentPhase(step, phaseCtx);
|
||||||
|
}
|
||||||
|
|
||||||
|
const match = await detectMatchedRule(step, response.content, tagContent, {
|
||||||
|
state,
|
||||||
|
cwd: this.deps.getCwd(),
|
||||||
|
});
|
||||||
|
if (match) {
|
||||||
|
log.debug('Rule matched', { step: step.name, ruleIndex: match.index, method: match.method });
|
||||||
|
response = { ...response, matchedRuleIndex: match.index, matchedRuleMethod: match.method };
|
||||||
|
}
|
||||||
|
|
||||||
|
state.stepOutputs.set(step.name, response);
|
||||||
|
this.emitStepReports(step);
|
||||||
|
return { response, instruction };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Emit step:report events for each report file that exists */
|
||||||
|
emitStepReports(step: WorkflowStep): void {
|
||||||
|
if (!step.report) return;
|
||||||
|
const baseDir = join(this.deps.getProjectCwd(), this.deps.getReportDir());
|
||||||
|
|
||||||
|
if (typeof step.report === 'string') {
|
||||||
|
this.checkReportFile(step, baseDir, step.report);
|
||||||
|
} else if (isReportObjectConfig(step.report)) {
|
||||||
|
this.checkReportFile(step, baseDir, step.report.name);
|
||||||
|
} else {
|
||||||
|
// ReportConfig[] (array)
|
||||||
|
for (const rc of step.report) {
|
||||||
|
this.checkReportFile(step, baseDir, rc.path);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collects report file paths that exist (used by WorkflowEngine to emit events)
|
||||||
|
private reportFiles: Array<{ step: WorkflowStep; filePath: string; fileName: string }> = [];
|
||||||
|
|
||||||
|
/** Check if report file exists and collect for emission */
|
||||||
|
private checkReportFile(step: WorkflowStep, baseDir: string, fileName: string): void {
|
||||||
|
const filePath = join(baseDir, fileName);
|
||||||
|
if (existsSync(filePath)) {
|
||||||
|
this.reportFiles.push({ step, filePath, fileName });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drain collected report files (called by engine after step execution) */
|
||||||
|
drainReportFiles(): Array<{ step: WorkflowStep; filePath: string; fileName: string }> {
|
||||||
|
const files = this.reportFiles;
|
||||||
|
this.reportFiles = [];
|
||||||
|
return files;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
413
src/workflow/engine/WorkflowEngine.ts
Normal file
413
src/workflow/engine/WorkflowEngine.ts
Normal file
@ -0,0 +1,413 @@
|
|||||||
|
/**
|
||||||
|
* Workflow execution engine.
|
||||||
|
*
|
||||||
|
* Orchestrates the main execution loop: step transitions, abort handling,
|
||||||
|
* loop detection, and iteration limits. Delegates step execution to
|
||||||
|
* StepExecutor (normal steps) and ParallelRunner (parallel steps).
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { EventEmitter } from 'node:events';
|
||||||
|
import { mkdirSync, existsSync, symlinkSync } from 'node:fs';
|
||||||
|
import { join } from 'node:path';
|
||||||
|
import type {
|
||||||
|
WorkflowConfig,
|
||||||
|
WorkflowState,
|
||||||
|
WorkflowStep,
|
||||||
|
AgentResponse,
|
||||||
|
} from '../../models/types.js';
|
||||||
|
import { COMPLETE_STEP, ABORT_STEP, ERROR_MESSAGES } from '../constants.js';
|
||||||
|
import type { WorkflowEngineOptions } from '../types.js';
|
||||||
|
import { determineNextStepByRules } from '../transitions.js';
|
||||||
|
import { LoopDetector } from '../loop-detector.js';
|
||||||
|
import { handleBlocked } from '../blocked-handler.js';
|
||||||
|
import {
|
||||||
|
createInitialState,
|
||||||
|
addUserInput as addUserInputToState,
|
||||||
|
incrementStepIteration,
|
||||||
|
} from '../state-manager.js';
|
||||||
|
import { generateReportDir } from '../../utils/session.js';
|
||||||
|
import { getErrorMessage } from '../../utils/error.js';
|
||||||
|
import { createLogger } from '../../utils/debug.js';
|
||||||
|
import { interruptAllQueries } from '../../claude/query-manager.js';
|
||||||
|
import { OptionsBuilder } from './OptionsBuilder.js';
|
||||||
|
import { StepExecutor } from './StepExecutor.js';
|
||||||
|
import { ParallelRunner } from './ParallelRunner.js';
|
||||||
|
|
||||||
|
const log = createLogger('engine');
|
||||||
|
|
||||||
|
// Re-export types for backward compatibility
|
||||||
|
export type {
|
||||||
|
WorkflowEvents,
|
||||||
|
UserInputRequest,
|
||||||
|
IterationLimitRequest,
|
||||||
|
SessionUpdateCallback,
|
||||||
|
IterationLimitCallback,
|
||||||
|
WorkflowEngineOptions,
|
||||||
|
} from '../types.js';
|
||||||
|
export { COMPLETE_STEP, ABORT_STEP } from '../constants.js';
|
||||||
|
|
||||||
|
/** Workflow engine for orchestrating agent execution */
|
||||||
|
export class WorkflowEngine extends EventEmitter {
|
||||||
|
private state: WorkflowState;
|
||||||
|
private config: WorkflowConfig;
|
||||||
|
private projectCwd: string;
|
||||||
|
private cwd: string;
|
||||||
|
private task: string;
|
||||||
|
private options: WorkflowEngineOptions;
|
||||||
|
private loopDetector: LoopDetector;
|
||||||
|
private reportDir: string;
|
||||||
|
private abortRequested = false;
|
||||||
|
|
||||||
|
private readonly optionsBuilder: OptionsBuilder;
|
||||||
|
private readonly stepExecutor: StepExecutor;
|
||||||
|
private readonly parallelRunner: ParallelRunner;
|
||||||
|
|
||||||
|
constructor(config: WorkflowConfig, cwd: string, task: string, options: WorkflowEngineOptions) {
|
||||||
|
super();
|
||||||
|
this.config = config;
|
||||||
|
this.projectCwd = options.projectCwd;
|
||||||
|
this.cwd = cwd;
|
||||||
|
this.task = task;
|
||||||
|
this.options = options;
|
||||||
|
this.loopDetector = new LoopDetector(config.loopDetection);
|
||||||
|
this.reportDir = `.takt/reports/${generateReportDir(task)}`;
|
||||||
|
this.ensureReportDirExists();
|
||||||
|
this.validateConfig();
|
||||||
|
this.state = createInitialState(config, options);
|
||||||
|
|
||||||
|
// Initialize composed collaborators
|
||||||
|
this.optionsBuilder = new OptionsBuilder(
|
||||||
|
options,
|
||||||
|
() => this.cwd,
|
||||||
|
(agent) => this.state.agentSessions.get(agent),
|
||||||
|
() => this.reportDir,
|
||||||
|
() => this.options.language,
|
||||||
|
);
|
||||||
|
|
||||||
|
this.stepExecutor = new StepExecutor({
|
||||||
|
optionsBuilder: this.optionsBuilder,
|
||||||
|
getCwd: () => this.cwd,
|
||||||
|
getProjectCwd: () => this.projectCwd,
|
||||||
|
getReportDir: () => this.reportDir,
|
||||||
|
getLanguage: () => this.options.language,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.parallelRunner = new ParallelRunner({
|
||||||
|
optionsBuilder: this.optionsBuilder,
|
||||||
|
stepExecutor: this.stepExecutor,
|
||||||
|
engineOptions: this.options,
|
||||||
|
getCwd: () => this.cwd,
|
||||||
|
getReportDir: () => this.reportDir,
|
||||||
|
});
|
||||||
|
|
||||||
|
log.debug('WorkflowEngine initialized', {
|
||||||
|
workflow: config.name,
|
||||||
|
steps: config.steps.map(s => s.name),
|
||||||
|
initialStep: config.initialStep,
|
||||||
|
maxIterations: config.maxIterations,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Ensure report directory exists (always in project root, not clone) */
|
||||||
|
private ensureReportDirExists(): void {
|
||||||
|
const reportDirPath = join(this.projectCwd, this.reportDir);
|
||||||
|
if (!existsSync(reportDirPath)) {
|
||||||
|
mkdirSync(reportDirPath, { recursive: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Worktree mode: create symlink so agents can access reports via relative path
|
||||||
|
if (this.cwd !== this.projectCwd) {
|
||||||
|
const cwdReportsDir = join(this.cwd, '.takt', 'reports');
|
||||||
|
if (!existsSync(cwdReportsDir)) {
|
||||||
|
mkdirSync(join(this.cwd, '.takt'), { recursive: true });
|
||||||
|
symlinkSync(
|
||||||
|
join(this.projectCwd, '.takt', 'reports'),
|
||||||
|
cwdReportsDir,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Validate workflow configuration at construction time */
|
||||||
|
private validateConfig(): void {
|
||||||
|
const initialStep = this.config.steps.find((s) => s.name === this.config.initialStep);
|
||||||
|
if (!initialStep) {
|
||||||
|
throw new Error(ERROR_MESSAGES.UNKNOWN_STEP(this.config.initialStep));
|
||||||
|
}
|
||||||
|
|
||||||
|
const stepNames = new Set(this.config.steps.map((s) => s.name));
|
||||||
|
stepNames.add(COMPLETE_STEP);
|
||||||
|
stepNames.add(ABORT_STEP);
|
||||||
|
|
||||||
|
for (const step of this.config.steps) {
|
||||||
|
if (step.rules) {
|
||||||
|
for (const rule of step.rules) {
|
||||||
|
if (rule.next && !stepNames.has(rule.next)) {
|
||||||
|
throw new Error(
|
||||||
|
`Invalid rule in step "${step.name}": target step "${rule.next}" does not exist`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get current workflow state */
|
||||||
|
getState(): WorkflowState {
|
||||||
|
return { ...this.state };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Add user input */
|
||||||
|
addUserInput(input: string): void {
|
||||||
|
addUserInputToState(this.state, input);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Update working directory */
|
||||||
|
updateCwd(newCwd: string): void {
|
||||||
|
this.cwd = newCwd;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get current working directory */
|
||||||
|
getCwd(): string {
|
||||||
|
return this.cwd;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get project root directory (where .takt/ lives) */
|
||||||
|
getProjectCwd(): string {
|
||||||
|
return this.projectCwd;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Request graceful abort: interrupt running queries and stop after current step */
|
||||||
|
abort(): void {
|
||||||
|
if (this.abortRequested) return;
|
||||||
|
this.abortRequested = true;
|
||||||
|
log.info('Abort requested');
|
||||||
|
interruptAllQueries();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Check if abort has been requested */
|
||||||
|
isAbortRequested(): boolean {
|
||||||
|
return this.abortRequested;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get step by name */
|
||||||
|
private getStep(name: string): WorkflowStep {
|
||||||
|
const step = this.config.steps.find((s) => s.name === name);
|
||||||
|
if (!step) {
|
||||||
|
throw new Error(ERROR_MESSAGES.UNKNOWN_STEP(name));
|
||||||
|
}
|
||||||
|
return step;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Update agent session and notify via callback if session changed */
|
||||||
|
private updateAgentSession(agent: string, sessionId: string | undefined): void {
|
||||||
|
if (!sessionId) return;
|
||||||
|
|
||||||
|
const previousSessionId = this.state.agentSessions.get(agent);
|
||||||
|
this.state.agentSessions.set(agent, sessionId);
|
||||||
|
|
||||||
|
if (this.options.onSessionUpdate && sessionId !== previousSessionId) {
|
||||||
|
this.options.onSessionUpdate(agent, sessionId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Emit step:report events collected by StepExecutor */
|
||||||
|
private emitCollectedReports(): void {
|
||||||
|
for (const { step, filePath, fileName } of this.stepExecutor.drainReportFiles()) {
|
||||||
|
this.emit('step:report', step, filePath, fileName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Run a single step (delegates to ParallelRunner if step has parallel sub-steps) */
|
||||||
|
private async runStep(step: WorkflowStep, prebuiltInstruction?: string): Promise<{ response: AgentResponse; instruction: string }> {
|
||||||
|
const updateSession = this.updateAgentSession.bind(this);
|
||||||
|
let result: { response: AgentResponse; instruction: string };
|
||||||
|
|
||||||
|
if (step.parallel && step.parallel.length > 0) {
|
||||||
|
result = await this.parallelRunner.runParallelStep(
|
||||||
|
step, this.state, this.task, this.config.maxIterations, updateSession,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
result = await this.stepExecutor.runNormalStep(
|
||||||
|
step, this.state, this.task, this.config.maxIterations, updateSession, prebuiltInstruction,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.emitCollectedReports();
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Determine next step for a completed step using rules-based routing.
|
||||||
|
*/
|
||||||
|
private resolveNextStep(step: WorkflowStep, response: AgentResponse): string {
|
||||||
|
if (response.matchedRuleIndex != null && step.rules) {
|
||||||
|
const nextByRules = determineNextStepByRules(step, response.matchedRuleIndex);
|
||||||
|
if (nextByRules) {
|
||||||
|
return nextByRules;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error(`No matching rule found for step "${step.name}" (status: ${response.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Build instruction (public, used by workflowExecution.ts for logging) */
|
||||||
|
buildInstruction(step: WorkflowStep, stepIteration: number): string {
|
||||||
|
return this.stepExecutor.buildInstruction(
|
||||||
|
step, stepIteration, this.state, this.task, this.config.maxIterations,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Run the workflow to completion */
|
||||||
|
async run(): Promise<WorkflowState> {
|
||||||
|
while (this.state.status === 'running') {
|
||||||
|
if (this.abortRequested) {
|
||||||
|
this.state.status = 'aborted';
|
||||||
|
this.emit('workflow:abort', this.state, 'Workflow interrupted by user (SIGINT)');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.state.iteration >= this.config.maxIterations) {
|
||||||
|
this.emit('iteration:limit', this.state.iteration, this.config.maxIterations);
|
||||||
|
|
||||||
|
if (this.options.onIterationLimit) {
|
||||||
|
const additionalIterations = await this.options.onIterationLimit({
|
||||||
|
currentIteration: this.state.iteration,
|
||||||
|
maxIterations: this.config.maxIterations,
|
||||||
|
currentStep: this.state.currentStep,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (additionalIterations !== null && additionalIterations > 0) {
|
||||||
|
this.config = {
|
||||||
|
...this.config,
|
||||||
|
maxIterations: this.config.maxIterations + additionalIterations,
|
||||||
|
};
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.state.status = 'aborted';
|
||||||
|
this.emit('workflow:abort', this.state, ERROR_MESSAGES.MAX_ITERATIONS_REACHED);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const step = this.getStep(this.state.currentStep);
|
||||||
|
const loopCheck = this.loopDetector.check(step.name);
|
||||||
|
|
||||||
|
if (loopCheck.shouldWarn) {
|
||||||
|
this.emit('step:loop_detected', step, loopCheck.count);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (loopCheck.shouldAbort) {
|
||||||
|
this.state.status = 'aborted';
|
||||||
|
this.emit('workflow:abort', this.state, ERROR_MESSAGES.LOOP_DETECTED(step.name, loopCheck.count));
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.state.iteration++;
|
||||||
|
|
||||||
|
// Build instruction before emitting step:start so listeners can log it
|
||||||
|
const isParallel = step.parallel && step.parallel.length > 0;
|
||||||
|
let prebuiltInstruction: string | undefined;
|
||||||
|
if (!isParallel) {
|
||||||
|
const stepIteration = incrementStepIteration(this.state, step.name);
|
||||||
|
prebuiltInstruction = this.stepExecutor.buildInstruction(
|
||||||
|
step, stepIteration, this.state, this.task, this.config.maxIterations,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this.emit('step:start', step, this.state.iteration, prebuiltInstruction ?? '');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { response, instruction } = await this.runStep(step, prebuiltInstruction);
|
||||||
|
this.emit('step:complete', step, response, instruction);
|
||||||
|
|
||||||
|
if (response.status === 'blocked') {
|
||||||
|
this.emit('step:blocked', step, response);
|
||||||
|
const result = await handleBlocked(step, response, this.options);
|
||||||
|
|
||||||
|
if (result.shouldContinue && result.userInput) {
|
||||||
|
this.addUserInput(result.userInput);
|
||||||
|
this.emit('step:user_input', step, result.userInput);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.state.status = 'aborted';
|
||||||
|
this.emit('workflow:abort', this.state, 'Workflow blocked and no user input provided');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextStep = this.resolveNextStep(step, response);
|
||||||
|
log.debug('Step transition', {
|
||||||
|
from: step.name,
|
||||||
|
status: response.status,
|
||||||
|
matchedRuleIndex: response.matchedRuleIndex,
|
||||||
|
nextStep,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (nextStep === COMPLETE_STEP) {
|
||||||
|
this.state.status = 'completed';
|
||||||
|
this.emit('workflow:complete', this.state);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextStep === ABORT_STEP) {
|
||||||
|
this.state.status = 'aborted';
|
||||||
|
this.emit('workflow:abort', this.state, 'Workflow aborted by step transition');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.state.currentStep = nextStep;
|
||||||
|
} catch (error) {
|
||||||
|
this.state.status = 'aborted';
|
||||||
|
if (this.abortRequested) {
|
||||||
|
this.emit('workflow:abort', this.state, 'Workflow interrupted by user (SIGINT)');
|
||||||
|
} else {
|
||||||
|
const message = getErrorMessage(error);
|
||||||
|
this.emit('workflow:abort', this.state, ERROR_MESSAGES.STEP_EXECUTION_FAILED(message));
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return this.state;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Run a single iteration (for interactive mode) */
|
||||||
|
async runSingleIteration(): Promise<{
|
||||||
|
response: AgentResponse;
|
||||||
|
nextStep: string;
|
||||||
|
isComplete: boolean;
|
||||||
|
loopDetected?: boolean;
|
||||||
|
}> {
|
||||||
|
const step = this.getStep(this.state.currentStep);
|
||||||
|
const loopCheck = this.loopDetector.check(step.name);
|
||||||
|
|
||||||
|
if (loopCheck.shouldAbort) {
|
||||||
|
this.state.status = 'aborted';
|
||||||
|
return {
|
||||||
|
response: {
|
||||||
|
agent: step.agent,
|
||||||
|
status: 'blocked',
|
||||||
|
content: ERROR_MESSAGES.LOOP_DETECTED(step.name, loopCheck.count),
|
||||||
|
timestamp: new Date(),
|
||||||
|
},
|
||||||
|
nextStep: ABORT_STEP,
|
||||||
|
isComplete: true,
|
||||||
|
loopDetected: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
this.state.iteration++;
|
||||||
|
const { response } = await this.runStep(step);
|
||||||
|
const nextStep = this.resolveNextStep(step, response);
|
||||||
|
const isComplete = nextStep === COMPLETE_STEP || nextStep === ABORT_STEP;
|
||||||
|
|
||||||
|
if (!isComplete) {
|
||||||
|
this.state.currentStep = nextStep;
|
||||||
|
} else {
|
||||||
|
this.state.status = nextStep === COMPLETE_STEP ? 'completed' : 'aborted';
|
||||||
|
}
|
||||||
|
|
||||||
|
return { response, nextStep, isComplete, loopDetected: loopCheck.isLoop };
|
||||||
|
}
|
||||||
|
}
|
||||||
10
src/workflow/engine/index.ts
Normal file
10
src/workflow/engine/index.ts
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
/**
|
||||||
|
* Workflow engine module.
|
||||||
|
*
|
||||||
|
* Re-exports the WorkflowEngine class and its supporting classes.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export { WorkflowEngine } from './WorkflowEngine.js';
|
||||||
|
export { StepExecutor } from './StepExecutor.js';
|
||||||
|
export { ParallelRunner } from './ParallelRunner.js';
|
||||||
|
export { OptionsBuilder } from './OptionsBuilder.js';
|
||||||
@ -6,7 +6,7 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
// Main engine
|
// Main engine
|
||||||
export { WorkflowEngine } from './engine.js';
|
export { WorkflowEngine } from './engine/index.js';
|
||||||
|
|
||||||
// Constants
|
// Constants
|
||||||
export { COMPLETE_STEP, ABORT_STEP, ERROR_MESSAGES } from './constants.js';
|
export { COMPLETE_STEP, ABORT_STEP, ERROR_MESSAGES } from './constants.js';
|
||||||
@ -33,7 +33,6 @@ export {
|
|||||||
createInitialState,
|
createInitialState,
|
||||||
addUserInput,
|
addUserInput,
|
||||||
getPreviousOutput,
|
getPreviousOutput,
|
||||||
storeStepOutput,
|
|
||||||
} from './state-manager.js';
|
} from './state-manager.js';
|
||||||
|
|
||||||
// Instruction building
|
// Instruction building
|
||||||
|
|||||||
@ -73,18 +73,3 @@ export function getPreviousOutput(state: WorkflowState): AgentResponse | undefin
|
|||||||
return outputs[outputs.length - 1];
|
return outputs[outputs.length - 1];
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Store a step output and update agent session.
|
|
||||||
*/
|
|
||||||
export function storeStepOutput(
|
|
||||||
state: WorkflowState,
|
|
||||||
stepName: string,
|
|
||||||
agentName: string,
|
|
||||||
response: AgentResponse
|
|
||||||
): void {
|
|
||||||
state.stepOutputs.set(stepName, response);
|
|
||||||
|
|
||||||
if (response.sessionId) {
|
|
||||||
state.agentSessions.set(agentName, response.sessionId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user